pmacs/docs/web-grammars-html-css-frami...

18 KiB
Raw Permalink Blame History

Web grammars (HTML + CSS) + HTML injections — framing

Revision 4 — implemented on branch web-grammars (PR #146). Ground truth: canonical main @ 4daa1b8 (after LaTeX #144 and inline-math-docs #145), 2026-07-23. Rev 2 settled the capture set, injection scope, and LSP claim (round 1); rev 3 corrected the #match? predicate analysis (round 2); rev 4 names the intended @attribute retro-paint on the already-bundled rust/lua/yaml grammars — verified on a Rust buffer and pinned by a test (round 3). See §0.1.

0.1 Revision history

Round 1 (rev 1 → rev 2)

  • F1 (high). Q#WEB4 deferred the capture mapping. The upstream queries settle it: HTML and CSS need exactly tag and attribute; @tag.error prefix-walks to tag; there is no @tag.delimiter (rev-1 speculation). Q#WEB4 now names both mappings and their styles, and acceptance 6 paints an HTML attribute explicitly (a tag/property-only test could pass with attribute unverified since property is already recognized).
  • F2 (medium). The HTML injections query has exactly two patterns — script_element→javascript, style_element→css. Q#WEB5 no longer claims event-handler / style= coverage; inline-attribute injection is now a named deferral.
  • F3 (medium). No HTML/CSS language server is bundled: lsp.lua's default config list ends at YAML (:247) and :521 returns nil without a configured command. §2 and §5 corrected — the grammars give stable language IDs so a user-supplied config attaches automatically, but nothing ships or starts by default.

Round 2 (rev 2 → rev 3)

  • R2-1 (medium). The rev-2 claim that pmacs ignores #match? was wrong. src/syntax.rs:1701 passes the source to QueryCursor::captures, so tree-sitter evaluates the standard #match?/#eq?/#any-of? text predicates natively (pmacs special-cases only #is? local, :1703). Corrected in §2 and Q#WEB4: an ordinary CSS property has a single @property capture; only a --custom-property also receives @variable (a benign double-capture). Acceptance 6 no longer claims an ordinary property pins custom-property precedence — it just verifies color@property paints.
  • R2-2 (low). The folding footprint in §0 was stale. At githubsucks/folding @ 036a994 the branch touches src/fold.rs, editor.rs, editor_core.rs, lib.rs, lua_bindings/{fold.rs,mod.rs}, semantic_render.rs, fold.lua, and tests — not overlay.rs/daemon.rs/syntax.rs. §0 now lists the real set; the zero-overlap conclusion is unchanged (stronger, if anything).

Round 3 (rev 3 → rev 4)

  • R3-1 (medium). Q#WEB4 verified which captures pmacs lacks but not the reverse: which already-bundled grammars use @attribute. Three do — rust (attribute_item), lua (<const>), yaml (directives) — so the new ("attribute", fg(3)) entry retro-paints their previously-unpainted spans yellow. Named in Q#WEB4 as intended (verified on a Rust buffer: uniform yellow, an improvement over unpainted), and pinned by rust_attribute_repaints_via_shared_attribute_capture. tag is unaffected (HTML/CSS only).

Add tree-sitter HTML and CSS grammars so .html/.htm and .css buffers get lexical highlighting, and — the north-star payoff — light up HTML's embedded-language injections: <script> → JavaScript (already registered) and <style> → CSS (added here). This is the side-quest backlog's "HTML/CSS grammars that light up more injection consumers."

0. Why this lane, why now (parallel-safety)

Board at this snapshot: LaTeX #144 and inline-math-docs #145 merged; folding Stage 1 (#142) is the only open PR — a headless fold engine. Its current diff (main...githubsucks/folding @ 036a994) touches src/fold.rs (new), src/editor.rs, src/editor_core.rs, src/lib.rs, src/lua_bindings/{fold.rs,mod.rs}, src/semantic_render.rs, builtin/runtime/fold.lua, and tests — none of which this lane touches. gpu-invocation has landed. The GPU render path (pmacs-gpu/src/main.rs) remains contended by folding's later Stage 3 — this lane never touches it.

This lane's footprint: two Cargo.toml grammar deps, two BUILTIN_LANGUAGES entries, and — per Q#WEB4 — a small extension of src/highlight.rs's recognized capture set. Zero file overlap with folding; the three files it edits:

  • src/syntax.rs — a localized append to the language table (after the latex entry, currently ~:1111). Folding does not touch this file.
  • Cargo.toml — two dependency lines in the grammar block (:142:220).
  • src/highlight.rs — appends to the capture-style entries table (:143-172). Folding does not touch this file.

All are localized, low-conflict edits — the same pattern #144 and the config-registry ∥ Vterm lanes landed conflict-free.

Unlike LaTeX (#144), no in-repo query overlay is needed — both crates export their queries as constants. The overlay convention #144 established stays available for future crates that don't (e.g. some of ruby/php).

1. What ships

  • HTML grammar (.html/.htm/.xhtml) — tags, attributes, text, comments, doctype.
  • CSS grammar (.css) — selectors, properties, values, at-rules, comments.
  • HTML injections<script>…</script> parses as JavaScript and <style>…</style> parses as CSS, via HTML's crate-exported injections query riding the #122 injection engine. CSS is registered here so the <style> injection resolves.

2. Ground truth (scouted 2026-07-23, main @ 4daa1b8)

Crate facts (verified)

  • tree-sitter-html 0.23.2 (2026-06). Exports LANGUAGE: LanguageFn, HIGHLIGHTS_QUERY, INJECTIONS_QUERY, NODE_TYPES. Deps: tree-sitter-language ^0.1 (the shim every pmacs grammar uses), tree-sitter ^0.24 dev-only → ABI-compatible with our tree-sitter 0.26 via .into(). Its INJECTIONS_QUERY is the load-bearing piece: it names javascript and css via #set! injection.language.
  • tree-sitter-css 0.25.0 (2026-05). Exports LANGUAGE: LanguageFn, HIGHLIGHTS_QUERY, NODE_TYPES. Deps: tree-sitter-language ^0.1, tree-sitter ^0.25 dev-only → shim-ABI-fine. No injections query (CSS injects nothing).

Both export their highlights query as a constant, so no overlay/vendoring (the LaTeX complication) applies.

Codebase

  • BUILTIN_LANGUAGES (src/syntax.rs:816];) currently ends at the latex entry (~:1104-1111, added by #144). No html/css entry. Append the two new entries before ];.
  • javascript is registered (src/syntax.rs:1010, extensions: js/mjs/cjs, injections_query: &[]), so HTML's <script>javascript resolves today. css does not exist → HTML's <style>css resolves only once this lane adds it.
  • Injection enginecollect_injection_matches (src/syntax.rs:375-427) reads @injection.content (:381) + injection.language (dynamic node text or #set!, :387-402) + injection.include-children (:402). resolve_injected_language (:304) resolves the name against BUILTIN_LANGUAGES + default_injection_aliases (:234). Working precedents: rust INJECTIONS_QUERY (:823), markdown block/inline (:848/:862). HTML's #set! injection.language "javascript"|"css" is the same shape.
  • Recognized capture setsrc/highlight.rs:143-172, resolved by a dotted-prefix walk (:189). Confirmed against the two upstream queries (HTML v0.23.2 highlights.scm, CSS v0.25.0 highlights.scm): the only captures not already recognized are @tag and @attribute — used by BOTH grammars (@tag.error prefix-walks to tag; there is no @tag.delimiter). Everything else maps: HTML's @constant (doctype), @string (attribute value), @comment, @punctuation.bracket; CSS's @operator/@property/ @function/@keyword/@number/@type/@string.special/@punctuation.*. CSS also has two #match?-gated @variable patterns for --custom-props. pmacs passes the buffer text to QueryCursor::captures (src/syntax.rs:1701), so tree-sitter evaluates the standard #match?/#eq?/#any-of? text predicates natively; pmacs adds handling only for the #is? local property predicate (the property_predicates … "local" filter at :1703). So an ordinary property (color) has a single @property capture, and only a custom property (--brand) additionally receives @variable — see Q#WEB4.
  • Detection — the extensions field drives language_name_for_path (:1223) ahead of the LSP filetype map in the Lua chain (builtin/runtime/syntax.lua:452-466); no Lua edit. No HTML/CSS language server is bundled: builtin/runtime/lsp.lua's default config list ends at YAML (:247), and :521 returns nil without a configured command. The grammars provide stable language IDs (html/css), so a user-supplied pmacs.lsp.config.html/.css attaches automatically, but nothing ships or starts by default.

3. Decisions

Q#WEB1 — Bundle tree-sitter-html 0.23 + tree-sitter-css 0.25

Add both to the Cargo.toml grammar block; loaders || tree_sitter_html::LANGUAGE.into() and || tree_sitter_css::LANGUAGE.into(). ABI is fine via the shared tree-sitter-language shim, as every current grammar. No provenance saga (these are the official tree-sitter-org grammars, not squatted republishes).

Q#WEB2 — Register both; CSS before HTML's injection can resolve

  • html: highlights_query: &[tree_sitter_html::HIGHLIGHTS_QUERY], injections_query: &[tree_sitter_html::INJECTIONS_QUERY], locals_query: &[].
  • css: highlights_query: &[tree_sitter_css::HIGHLIGHTS_QUERY], injections and locals empty.

Both live in the same BUILTIN_LANGUAGES, so resolve_injected_language finds css (and the existing javascript) when HTML's injections query fires.

Q#WEB3 — Extensions

html: ["html", "htm", "xhtml"]. css: ["css"]. SCSS/LESS/Sass are distinct grammars (scss/less node sets) and are deferred (§5).

Q#WEB4 — Add exactly two capture entries: tag and attribute

The LaTeX lane could rename captures because it owned an editable overlay. Here the highlights queries are crate constants — not editable — so the reconciliation is to extend src/highlight.rs's entries table (:143-172). The upstream queries settle the exact set: the only captures neither grammar's query already resolves are tag and attribute. Add exactly two entries:

  • ("tag", fg(5)) — HTML (tag_name) and CSS element/nesting/universal selectors, in the keyword hue (magenta) but non-bold to stay light in dense markup. @tag.error (HTML erroneous end tags) prefix-walks to this entry, so it needs no separate mapping.
  • ("attribute", fg(3)) — HTML (attribute_name) and CSS pseudo-/attribute-selector names, in the type hue (yellow) — distinct from tag, from property/operator (cyan), and from string values (green).

There is no tag.delimiter (rev-1 speculation, removed); HTML's </>/ <///> are @punctuation.bracket, already handled. This extension is:

  • low-conflict — folding does not touch highlight.rs;
  • generaltag/attribute are standard tree-sitter web captures, so it also serves future html-ish grammars (vue/svelte/astro).

Retro-paint on already-bundled languages (intended, added rev 4). The capture table is global, so adding attribute also colours the @attribute capture that three bundled grammars already emit but which was previously unrecognized (and so unpainted): rust (attribute_item/inner_attribute_item — every #[derive(…)] / #![…]), lua (the <const>/<close> variable attribute), and yaml (%YAML/%TAG directives). On merge these begin painting attribute yellow (fg 3). Verified on a Rust buffer: #[derive(Debug)] paints uniformly yellow (the attribute_item span carries no narrower overriding captures) — a distinct-attribute convention most editors follow, and an improvement over unpainted. This is chosen, not incidental: it is pinned by rust_attribute_repaints_via_shared_attribute_capture (src/highlight.rs). tag is clean — only HTML/CSS use it, so it has no retro-effect.

On CSS custom properties (corrected in rev 3): pmacs passes the buffer text to QueryCursor::captures (src/syntax.rs:1701), so tree-sitter evaluates the standard #match?/#eq?/#any-of? predicates natively — pmacs special-cases only #is? local. So an ordinary property (color) matches only the unconditional (property_name) @property and paints cleanly; a custom property (--brand) additionally satisfies #match? "^--" and also receives @variable — a benign double-capture whose winner is a within-layer precedence detail, out of scope for v0 and not relied upon. Rejected: a shadow overlay re-capturing the same nodes with recognized names (fragile, precedence-dependent, duplicative).

Q#WEB5 — Injection scope: script + style only

HTML's INJECTIONS_QUERY (v0.23.2) has exactly two patterns: (script_element (raw_text) @injection.content) (#set! injection.language "javascript") and the same for (style_element …)"css". There is no event-handler (onclick=…) or inline style= attribute injection — those are a named deferral (§5). Each element is its own subtree, so injection.combined (many matches → one shared parse, the PHP-in-HTML case) is not needed and stays deferred. Acceptance pins script + style.

Q#WEB6 — No protocol, frontend, or GPU change

Pure instance-side: two grammar entries, one injections query, a capture-table extension. No wire type, no TUI/GPU edit.

4. Categorical bets

  1. The shim makes ABI a non-issue. Both crates ride tree-sitter-language 0.1, exactly like every bundled grammar.
  2. HTML's value is the injection, not the tags. Highlighting a web page with its embedded JS and CSS is the north-star injection consumer; registering CSS is the prerequisite, which is why the two grammars ship together as one lane.
  3. Extending the core capture table is the correct reconciliation for crate-exported queries. You cannot rename a const query's captures; teaching the highlighter the standard web captures is the general fix and costs one localized, uncontended edit.

5. Deferred (named)

  • SCSS / LESS / Sass grammars (distinct node sets; own extensions).
  • injection.combined (PHP-in-HTML and other many→one-parse schemes) — the existing side-quest deferral; not needed for script/style.
  • CSS-in-JS / HTML-in-JS via tree-sitter-javascript's INJECTIONS_QUERY (the javascript entry is injections_query: &[] today) — a clean follow-up: add the JS injections query so tagged template literals (css`…`, html`…`) parse. Separate consumer, separate PR.
  • Inline HTML attribute injection — event handlers (onclick=…) and style= attributes are not in the upstream injections query (Q#WEB5); adding them would need a pmacs-side injections overlay.
  • Vue / Svelte / Astro single-file-component grammars.
  • Bundled HTML/CSS LSP configs — no server ships today (§2). Shipping default pmacs.lsp.config.html/.css entries (vscode-langservers-extracted) is a separate follow-up; until then the grammar's stable language ID lets a user config attach automatically.

6. Acceptance

Mirrors the #144 / CUDA conventions (src/syntax.rs table guards + smokes; src/highlight.rs paint template grid_paints_injected_child_keyword).

  1. Table guards builtin_languages_include_html / _css: entries exist, claim their extensions, carry non-empty highlights; html also carries INJECTIONS_QUERY.
  2. Load-and-parse smokes: a minimal HTML document and a CSS rule parse with the expected root node kind and !has_error() (exact kinds — likely document / stylesheet — confirmed at implementation).
  3. Highlights resolve: reg.highlights_query("html") / ("css") compile against their grammars (the node-name compatibility gate).
  4. Extension resolution: .html/.htm/.xhtmlhtml; .csscss.
  5. Injection (the payoff): an HTML buffer with <style>a{color:red}</style> and <script>let x=1</script> produces a ParseTreeBundle whose child layers resolve to css and javascript, and a grid-paint asserts an injected CSS property and JS keyword paint inside the embedded regions (mirroring grid_paints_injected_child_keyword).
  6. Paint (non-vacuous highlighting): parse <a href="x"> and assert both the <a> tag (@tag) and the href attribute name (@attribute) paint the two new non-default styles — the attribute assertion is load-bearing, since a tag-only test could pass with @attribute unverified. Also assert a CSS selector (@tag) paints the new tag style and an ordinary CSS property (color@property) paints non-default (an ordinary property has a single unconditional capture, so no precedence subtlety — Q#WEB4).
  7. Full gate suite per CLAUDE.md.

7. Prior art in pmacs

  • LaTeX lane #144 (docs/latex-grammar-math-substrate-framing.md) — the grammar-add mechanics, the table-guard/smoke/paint test conventions, and the compile-gate-plus-paint-test discipline. (Its overlay convention is not needed here.)
  • Multi-language injections #122 (docs/multi-language-injections-framing.md) — the ParseTreeBundle + Layer engine HTML's injections ride.
  • markdown → rust fenced code (src/syntax.rs:848, tested by src/highlight.rs's grid_paints_injected_child_keyword) — the working injection precedent this lane's acceptance 5 mirrors.