feat(web): bundle HTML + CSS grammars + HTML injections

Register `html` (.html/.htm/.xhtml) and `css` (.css) entries in
BUILTIN_LANGUAGES, backed by the official tree-sitter-html 0.23 and
tree-sitter-css 0.25 grammars over the tree-sitter-language shim (ABI-fine, no
overlay — both export their query constants). The single `extensions` field
wires detection ahead of the LSP filetype map.

HTML's crate-exported INJECTIONS_QUERY lights up <script> -> javascript
(already registered) and <style> -> css (registered here) via the #122
injection engine — the north-star injection consumer.

The only capture reconciliation (Q#WEB4): the two web captures both grammars'
queries use that pmacs did not recognize — ("tag", fg(5)) and
("attribute", fg(3)) — added to highlight.rs's table; @tag.error prefix-walks
to tag, and everything else already maps.

Tests: table guards; load-and-parse smokes (roots document/stylesheet);
highlights-resolve (node-name compat gate, asserts @tag present); extension
resolution; a tag+attribute paint test (the attribute assertion is
load-bearing); and the injection payoff — an HTML buffer with embedded
<style>/<script> paints a CSS property and a JS keyword INSIDE the injected
regions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 15:04:10 -04:00
parent 05eab9e4e5
commit 394d39942c
4 changed files with 298 additions and 0 deletions

22
Cargo.lock generated
View File

@ -2566,8 +2566,10 @@ dependencies = [
"tree-sitter-cmake",
"tree-sitter-containerfile",
"tree-sitter-cpp",
"tree-sitter-css",
"tree-sitter-cuda",
"tree-sitter-go",
"tree-sitter-html",
"tree-sitter-javascript",
"tree-sitter-json",
"tree-sitter-lua",
@ -3794,6 +3796,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-css"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cuda"
version = "0.21.1"
@ -3814,6 +3826,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-html"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.25.0"

View File

@ -233,6 +233,14 @@ tree-sitter-md = "0.5"
# uses the in-repo overlay `builtin/queries/latex/highlights.scm` (the first
# such overlay; `include_str!`'d in `crate::syntax::BUILTIN_LANGUAGES`).
codebook-tree-sitter-latex = "0.6"
# Web grammars: HTML + CSS (`.html`/`.htm`/`.xhtml`, `.css`). The official
# tree-sitter-org grammars; both export `LANGUAGE` + query constants over
# `tree-sitter-language 0.1` (shared ABI crate, `tree-sitter` dev-only), so no
# overlay is needed. HTML's `INJECTIONS_QUERY` lights up `<script>` ->
# javascript (already registered) and `<style>` -> css via the #122 injection
# engine (see `crate::syntax::BUILTIN_LANGUAGES`).
tree-sitter-html = "0.23"
tree-sitter-css = "0.25"
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
# the feature surface tight to keep build time low. `poll` feeds the
# compile-mode group readers (cancellable poll-based reads, Q#CM3).

View File

@ -169,6 +169,11 @@ impl Theme {
("decorator", fg(13)),
("regexp", fg(2)),
("typeParameter", fg_italic(11)),
// Web grammars (HTML/CSS, framing Q#WEB4): the only captures their
// crate-exported queries use that the set above lacks. `@tag.error`
// prefix-walks to `tag`.
("tag", fg(5)),
("attribute", fg(3)),
];
let by_capture = entries
.iter()
@ -1410,4 +1415,124 @@ mod tests {
"the \\section title text is painted @keyword.control (bold)"
);
}
#[test]
fn web_grid_paints_html_tag_and_attribute() {
// Q#WEB4 acceptance: the two capture entries this lane adds (`tag`,
// `attribute`) actually reach painted cells. The attribute assertion is
// load-bearing — a tag-only test could pass with `@attribute` unverified.
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::cell::{Cell, CellSize};
use crate::syntax::{ParseView, SyntaxRegistry};
let reg = SyntaxRegistry::new();
let language = reg.language("html").expect("html grammar");
// Line 0: <a href="x">Hi</a> — `a` (tag_name) at col 1, `href`
// (attribute_name) at col 3.
let src = b"<a href=\"x\">Hi</a>\n";
let mut buf = Buffer::new(BufferId::next(), "index.html");
buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
.unwrap();
let view = ParseView::new(&buf, language, "html".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let mut req = handle.make_request();
req.injection_aliases = reg.injection_alias_snapshot();
let bundle = crate::syntax::run_parse(req).expect("html parse");
handle.install(reg.resolve_layer_queries(&bundle));
let mut hv = SyntaxHighlightView::new(handle, reg.theme());
let (rows, cols) = (1usize, 40usize);
let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
let mut grid = CellGrid {
cells: &mut backing,
stride: cols as u32,
size: CellSize::new(rows as u32, cols as u32),
};
let viewport = Viewport {
buffer_start: 0,
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
let tag_cell = grid.get(CellCoord::new(0, 1));
let attr_cell = grid.get(CellCoord::new(0, 3));
assert_ne!(
tag_cell.style,
Cell::default().style,
"the <a> tag_name paints @tag (non-default)"
);
assert_ne!(
attr_cell.style,
Cell::default().style,
"the href attribute_name paints @attribute (non-default)"
);
assert_ne!(
tag_cell.style, attr_cell.style,
"@tag and @attribute use the two distinct new styles"
);
}
#[test]
fn html_injects_css_and_js() {
// The payoff (Q#WEB acceptance 5): HTML's INJECTIONS_QUERY parses
// <style> as CSS and <script> as JavaScript, and the child layers paint
// INSIDE the embedded regions — styling only the injected grammars can
// produce. `css` is resolvable only because this lane registered it.
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::cell::{Cell, CellSize};
use crate::syntax::{ParseView, SyntaxRegistry};
let reg = SyntaxRegistry::new();
let language = reg.language("html").expect("html grammar");
// Line 0: <style>a{color:red}</style> — `color` (CSS property) at col 9.
// Line 1: <script>let x=1</script> — `let` (JS keyword) at col 8.
let src = b"<style>a{color:red}</style>\n<script>let x=1</script>\n";
let mut buf = Buffer::new(BufferId::next(), "page.html");
buf.apply_edit(EditOp::Insert { pos: 0, bytes: src })
.unwrap();
let view = ParseView::new(&buf, language, "html".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let mut req = handle.make_request();
req.injection_aliases = reg.injection_alias_snapshot();
let bundle = crate::syntax::run_parse(req).expect("html parse");
handle.install(reg.resolve_layer_queries(&bundle));
let mut hv = SyntaxHighlightView::new(handle, reg.theme());
let (rows, cols) = (2usize, 60usize);
let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
let mut grid = CellGrid {
cells: &mut backing,
stride: cols as u32,
size: CellSize::new(rows as u32, cols as u32),
};
let viewport = Viewport {
buffer_start: 0,
buffer_end: u64::MAX,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
// Inside <style>: the CSS `color` property paints (non-default) —
// proves the <style> -> css injection resolved and parsed.
assert_ne!(
grid.get(CellCoord::new(0, 9)).style,
Cell::default().style,
"the CSS `color` property paints inside the <style> injection"
);
// Inside <script>: the JS `let` keyword paints bold — proves the
// <script> -> javascript injection resolved and parsed.
assert!(
grid.get(CellCoord::new(1, 8)).style.bold,
"the JS `let` keyword paints (bold) inside the <script> injection"
);
}
}

View File

@ -1108,6 +1108,28 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
locals_query: &[],
injections_query: &[],
},
// HTML + CSS (framing `docs/web-grammars-html-css-framing.md`). Both crates
// export their query constants (no overlay). HTML's `INJECTIONS_QUERY`
// wires `<script>` -> javascript (already registered) and `<style>` -> css
// (below), riding the #122 injection engine; `css` must be registered here
// for that injection to resolve. The `tag`/`attribute` captures these
// queries use are taught to the highlighter in `crate::highlight` (Q#WEB4).
LanguageEntry {
name: "html",
extensions: &["html", "htm", "xhtml"],
loader: || tree_sitter_html::LANGUAGE.into(),
highlights_query: &[tree_sitter_html::HIGHLIGHTS_QUERY],
locals_query: &[],
injections_query: &[tree_sitter_html::INJECTIONS_QUERY],
},
LanguageEntry {
name: "css",
extensions: &["css"],
loader: || tree_sitter_css::LANGUAGE.into(),
highlights_query: &[tree_sitter_css::HIGHLIGHTS_QUERY],
locals_query: &[],
injections_query: &[],
},
];
/// LaTeX highlights overlay (framing Q#LX2). The chosen grammar crate
@ -2372,6 +2394,127 @@ mod tests {
}
}
#[test]
fn builtin_languages_include_html_and_css() {
// Both crate grammars export their query constants (no overlay). HTML
// additionally carries an injections query (script/style); CSS does not.
let html = BUILTIN_LANGUAGES
.iter()
.find(|l| l.name == "html")
.expect("`html` language entry must be present");
for ext in ["html", "htm", "xhtml"] {
assert!(html.extensions.contains(&ext), "`html` claims `.{ext}`");
}
assert!(
!html.highlights_query.is_empty(),
"`html` ships a highlights query"
);
assert!(
!html.injections_query.is_empty(),
"`html` ships an injections query (script/style)"
);
let css = BUILTIN_LANGUAGES
.iter()
.find(|l| l.name == "css")
.expect("`css` language entry must be present");
assert!(css.extensions.contains(&"css"), "`css` claims `.css`");
assert!(
!css.highlights_query.is_empty(),
"`css` ships a highlights query"
);
}
#[test]
fn html_grammar_loads_and_parses() {
// ABI acceptance: `tree-sitter-html` (LanguageFn over
// `tree-sitter-language 0.1`) is accepted by our `tree-sitter` 0.26 core.
let reg = SyntaxRegistry::new();
let language = reg
.language("html")
.expect("`html` language loads from BUILTIN_LANGUAGES");
let mut buf = fresh_buffer("index.html");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"<!DOCTYPE html>\n<html><body><a href=\"x\">Hi</a></body></html>\n",
})
.unwrap();
let view = ParseView::new(&buf, language, "html".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let bundle = parse_synchronously(&handle);
assert_eq!(
bundle.root_tree().root_node().kind(),
"document",
"HTML grammar roots at document"
);
assert!(
!bundle.root_tree().root_node().has_error(),
"HTML grammar parses a document without error"
);
}
#[test]
fn css_grammar_loads_and_parses() {
let reg = SyntaxRegistry::new();
let language = reg
.language("css")
.expect("`css` language loads from BUILTIN_LANGUAGES");
let mut buf = fresh_buffer("style.css");
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"a { color: red; }\n",
})
.unwrap();
let view = ParseView::new(&buf, language, "css".to_owned());
let handle = view.handle();
let _vid = buf.attach_view(Box::new(view));
let bundle = parse_synchronously(&handle);
assert_eq!(
bundle.root_tree().root_node().kind(),
"stylesheet",
"CSS grammar roots at stylesheet"
);
assert!(
!bundle.root_tree().root_node().has_error(),
"CSS grammar parses a rule without error"
);
}
#[test]
fn html_and_css_highlights_resolve() {
// The crate-exported queries compile against their grammars (node-name
// compatibility gate), and both use the `@tag` capture this lane teaches
// the highlighter (Q#WEB4).
let reg = SyntaxRegistry::new();
for lang in ["html", "css"] {
let query = reg
.highlights_query(lang)
.unwrap_or_else(|| panic!("{lang} highlights compile against the grammar"));
let names = query.capture_names();
assert!(
names.contains(&"tag"),
"{lang} highlights use the @tag capture; got {names:?}"
);
}
}
#[test]
fn language_for_path_resolves_web_extensions() {
let reg = SyntaxRegistry::new();
for (path, lang) in [
("index.html", "html"),
("page.htm", "html"),
("doc.xhtml", "html"),
("style.css", "css"),
] {
assert_eq!(
reg.language_name_for_path(path).as_deref(),
Some(lang),
"{path} resolves to {lang}"
);
}
}
#[test]
fn builtin_languages_include_bash() {
// Regression guard: the bash entry claims the wider shell family