chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
serve: highlight Svelte, match lockfiles and dotfiles by name
Svelte uses tree-sitter-svelte-ng with HTML's highlights under its own,
as its `; inherits: html` asks, and an injections query of our own: the
bundled one leans on Neovim's `inherits` and `#any-of?`. A script's
`lang` attribute names its language, so `<script lang="ts">` is
TypeScript; `{expressions}` are JavaScript, styles CSS. `{#if`, `{:else}`
and `{/if}` read as keywords.
Languages now match whole file names as well as extensions. `.lock` no
longer means TOML: Cargo.lock, uv.lock, poetry.lock and pdm.lock are TOML;
flake.lock, deno.lock, composer.lock and Pipfile.lock are JSON; yarn.lock
is plain. Shell dotfiles (.bashrc, .zshrc, .envrc, ...) are bash, and
jsonc and webmanifest are JSON.
Cargo.lock still needs the tree-sitter-svelte-ng entry: run `cargo build`
before merging, as the Dockerfile builds with --locked.Showing 2 changed files with 126 additions and 6 deletions
crates/knotless-serve/Cargo.toml+1−0
| @@ -48,6 +48,7 @@ tree-sitter-html = "0.23.2" | ||
tree-sitter-css = "0.25.0" | ||
tree-sitter-lua = "0.5.0" | ||
tree-sitter-zig = "1.1.2" | ||
tree-sitter-svelte-ng = "1.0.2" | ||
tree-sitter-language = "0.1.7" | ||
ts-rs = "12" | ||
| ||
crates/knotless-serve/src/highlight.rs+125−6
| @@ -22,7 +22,7 @@ const CODE: &[&str] = &[ | ||
/// The captures the grammars' queries use, and the highlight each gives. | ||
/// A capture takes its longest listed prefix: `function.method` is a | ||
/// `function`. | ||
const CAPTURES: [(&str, usize); 23] = [ | ||
const CAPTURES: [(&str, usize); 24] = [ | ||
("comment", 0), | ||
("string", 1), | ||
("string.special.key", 7), | ||
| @@ -43,6 +43,8 @@ const CAPTURES: [(&str, usize); 23] = [ | ||
("type.builtin", 5), | ||
("constructor", 5), | ||
("tag", 6), | ||
// Svelte's `#`, `:` and `/` read with the word they open: `{#if`. | ||
("tag.delimiter", 2), | ||
("attribute", 7), | ||
("property.definition", 7), | ||
("label", 2), | ||
| @@ -54,8 +56,36 @@ const ROWS: &[&str] = &[ | ||
"comment", "string", "keyword", "number", "function", "type", "tag", "attr", "xins", "xdel", | ||
]; | ||
| ||
/// Svelte's scripts, styles and `{expressions}`. The grammar's own query | ||
/// leans on Neovim's `; inherits:` and `#any-of?`, which tree-sitter-highlight | ||
/// doesn't read. A `<script lang="ts">` names its language by the attribute, | ||
/// resolved by [`injected`]; one without matches only the second pattern. | ||
const SVELTE_INJECTIONS: &str = r#" | ||
((script_element | ||
(start_tag | ||
(attribute | ||
(attribute_name) @_attr | ||
(quoted_attribute_value (attribute_value) @injection.language))) | ||
(raw_text) @injection.content) | ||
(#eq? @_attr "lang")) | ||
| ||
((script_element | ||
(start_tag) @_tag | ||
(raw_text) @injection.content) | ||
(#not-match? @_tag "\\slang\\s*=") | ||
(#set! injection.language "javascript")) | ||
| ||
((style_element | ||
(raw_text) @injection.content) | ||
(#set! injection.language "css")) | ||
| ||
((svelte_raw_text) @injection.content | ||
(#set! injection.language "javascript")) | ||
"#; | ||
| ||
struct Language { | ||
name: &'static str, | ||
/// Extensions, and whole file names: `rs`, `Gemfile`, `Cargo.lock`. | ||
extensions: &'static [&'static str], | ||
config: HighlightConfiguration, | ||
} | ||
| @@ -162,14 +192,36 @@ static LANGUAGES: LazyLock<Vec<Language>> = LazyLock::new(|| { | ||
), | ||
language( | ||
"bash", | ||
&["sh", "bash", "zsh"], | ||
&[ | ||
"sh", | ||
"bash", | ||
"zsh", | ||
".bashrc", | ||
".bash_profile", | ||
".bash_aliases", | ||
".bash_logout", | ||
".profile", | ||
".zshrc", | ||
".zshenv", | ||
".zprofile", | ||
".envrc", | ||
], | ||
tree_sitter_bash::LANGUAGE, | ||
&[tree_sitter_bash::HIGHLIGHT_QUERY], | ||
"", | ||
), | ||
language( | ||
"json", | ||
&["json"], | ||
// Lockfiles by name: not every `.lock` is TOML. | ||
&[ | ||
"json", | ||
"jsonc", | ||
"webmanifest", | ||
"flake.lock", | ||
"deno.lock", | ||
"composer.lock", | ||
"Pipfile.lock", | ||
], | ||
tree_sitter_json::LANGUAGE, | ||
// Where patterns overlap the last wins: keys over strings. | ||
&[ | ||
| @@ -180,7 +232,7 @@ static LANGUAGES: LazyLock<Vec<Language>> = LazyLock::new(|| { | ||
), | ||
language( | ||
"toml", | ||
&["toml", "lock"], | ||
&["toml", "Cargo.lock", "uv.lock", "poetry.lock", "pdm.lock"], | ||
tree_sitter_toml_ng::LANGUAGE, | ||
&[tree_sitter_toml_ng::HIGHLIGHTS_QUERY], | ||
"", | ||
| @@ -199,6 +251,17 @@ static LANGUAGES: LazyLock<Vec<Language>> = LazyLock::new(|| { | ||
&[tree_sitter_html::HIGHLIGHTS_QUERY], | ||
tree_sitter_html::INJECTIONS_QUERY, | ||
), | ||
language( | ||
"svelte", | ||
&["svelte"], | ||
tree_sitter_svelte_ng::LANGUAGE, | ||
// Its query builds on HTML's, as Neovim's `; inherits: html` says. | ||
&[ | ||
tree_sitter_html::HIGHLIGHTS_QUERY, | ||
tree_sitter_svelte_ng::HIGHLIGHTS_QUERY, | ||
], | ||
SVELTE_INJECTIONS, | ||
), | ||
language( | ||
"css", | ||
&["css"], | ||
| @@ -231,11 +294,22 @@ fn by_name(name: &str) -> Option<&'static Language> { | ||
LANGUAGES.iter().find(|l| l.name == name) | ||
} | ||
| ||
/// The language an injection names, as a query sets it or as a `lang` | ||
/// attribute spells it. | ||
fn injected(name: &str) -> Option<&'static Language> { | ||
match name { | ||
"ts" => by_name("typescript"), | ||
"js" => by_name("javascript"), | ||
_ => by_name(name), | ||
} | ||
} | ||
| ||
/// The language of the file at `path`: by its extension, or its name, or | ||
/// the interpreter its first line names. | ||
fn language_for(path: &str, text: &str) -> Option<&'static Language> { | ||
let name = path.rsplit('/').next().unwrap_or(path); | ||
let ext = name.rsplit_once('.').map_or(name, |(_, ext)| ext); | ||
let whole = LANGUAGES.iter().find(|l| l.extensions.contains(&name)); | ||
let by_ext = LANGUAGES.iter().find(|l| l.extensions.contains(&ext)); | ||
let shebang = text | ||
.lines() | ||
| @@ -250,7 +324,7 @@ fn language_for(path: &str, text: &str) -> Option<&'static Language> { | ||
"ruby" => by_name("ruby"), | ||
_ => None, | ||
}; | ||
by_ext.or(by_shebang) | ||
whole.or(by_ext).or(by_shebang) | ||
} | ||
| ||
/// Runs of each named highlight, as `[start, end)` in UTF-16 units. | ||
| @@ -315,7 +389,7 @@ pub fn code(path: &str, text: &str) -> Option<Ranges> { | ||
HIGHLIGHTER.with_borrow_mut(|highlighter| { | ||
let events = highlighter | ||
.highlight(&language.config, text.as_bytes(), None, None, |name| { | ||
by_name(name).map(|l| &l.config) | ||
injected(name).map(|l| &l.config) | ||
}) | ||
.ok()?; | ||
let mut ranges = Ranges::new(CODE); | ||
| @@ -481,6 +555,7 @@ mod tests { | ||
fn every_language_parses() { | ||
let samples = [ | ||
("a.js", "const f = () => <b x=\"1\">hi</b>; // c\n"), | ||
("a.jsx", "const f = () => <b x=\"1\">hi</b>;\n"), | ||
("a.ts", "let x: number = 1;\n"), | ||
("a.tsx", "const e = <div class=\"x\" />;\n"), | ||
("a.py", "def f():\n return 'x'\n"), | ||
| @@ -490,11 +565,16 @@ mod tests { | ||
("A.java", "class A { void f() {} }\n"), | ||
("Gemfile", "gem 'x'\n"), | ||
("a.json", "{\"key\": 1}\n"), | ||
("tsconfig.jsonc", "// c\n{\"key\": 1}\n"), | ||
("Cargo.lock", "[[package]]\nname = \"x\"\n"), | ||
("flake.lock", "{\"nodes\": {}}\n"), | ||
("a.yml", "a: 'b'\n"), | ||
("a.css", "a { color: red; }\n"), | ||
("a.lua", "local x = 'y'\n"), | ||
("a.zig", "const x = 1;\n"), | ||
("a.svelte", "<p class=\"x\">{n}</p>\n"), | ||
("home/.bashrc", "alias ll='ls -l'\n"), | ||
(".envrc", "export A='b'\n"), | ||
("run", "#!/usr/bin/env bash\necho 'hi'\n"), | ||
("tool", "#!/usr/bin/python3.12\nx = 'y'\n"), | ||
("cli", "#!/usr/bin/env node\nlet a = 'b'\n"), | ||
| @@ -511,6 +591,17 @@ mod tests { | ||
); | ||
} | ||
| ||
#[test] | ||
fn lockfiles_by_name() { | ||
let json = "{\"nodes\": {}}\n"; | ||
let r = code("flake.lock", json).unwrap(); | ||
assert_eq!(spans(&r, json, "attr"), ["\"nodes\""]); | ||
let toml = "[[package]]\nname = \"x\"\n"; | ||
let r = code("Cargo.lock", toml).unwrap(); | ||
assert_eq!(spans(&r, toml, "string"), ["\"x\""]); | ||
assert_eq!(code("yarn.lock", "a@1:\n version \"1\"\n"), None); | ||
} | ||
| ||
#[test] | ||
fn html_carries_its_scripts_and_styles() { | ||
let html = | ||
| @@ -525,6 +616,34 @@ mod tests { | ||
assert!(spans(&r, html, "tag").contains(&"script".to_string())); | ||
} | ||
| ||
#[test] | ||
fn svelte() { | ||
let text = "<script lang=\"ts\">\n let n: number = 1;\n</script>\n\n\ | ||
{#if n > 0}\n <Count value={n} on:click={() => n++} />\n{:else}\n <p>none</p>\n{/if}\n\n\ | ||
<style>\n p { margin: 4px; }\n</style>\n"; | ||
let r = code("src/App.svelte", text).unwrap(); | ||
let keyword = spans(&r, text, "keyword"); | ||
for k in ["let", "#if", ":else", "/if"] { | ||
assert!(keyword.contains(&k.to_string()), "{k} in {keyword:?}"); | ||
} | ||
// TypeScript, as the script's `lang` says. | ||
assert!(spans(&r, text, "type").contains(&"number".to_string())); | ||
let number = spans(&r, text, "number"); | ||
assert!(number.contains(&"1".to_string()) && number.contains(&"0".to_string())); | ||
let tag = spans(&r, text, "tag"); | ||
for t in ["script", "Count", "p", "style"] { | ||
assert!(tag.contains(&t.to_string()), "{t} in {tag:?}"); | ||
} | ||
assert!(spans(&r, text, "attr").contains(&"lang".to_string())); | ||
// The style is CSS. | ||
assert!(number.contains(&"4".to_string()), "{number:?}"); | ||
| ||
let js = "<script>\n let a = 'q';\n</script>\n"; | ||
let r = code("a.svelte", js).unwrap(); | ||
assert!(spans(&r, js, "string").contains(&"'q'".to_string())); | ||
assert!(spans(&r, js, "keyword").contains(&"let".to_string())); | ||
} | ||
| ||
#[test] | ||
fn what_it_leaves_plain() { | ||
assert_eq!(code("notes.unknown-kind", "plain\n"), None); | ||