docs(injections): framing for multi-language injections

Frame the tree-sitter injection-layer engine: after the root parse,
run the grammar's injections.scm, parse each embedded region with the
injected language, and merge every layer's highlight spans. First
consumer is markdown fenced code + inline (zero new grammars).

Eleven numbered decisions (Q#IJ1-IJ11) and thirteen acceptance tests,
approved over three framing rounds. Load-bearing calls: the layer set
lives inside ParseTreeBundle installed atomically (Q#IJ1); a two-stage
worker/settle handoff keeps parsing off the main thread and query
caching in the Rc registry (Q#IJ2); recursion is depth/count/visited
bounded with child-only failure (Q#IJ3); a case-folded alias map
snapshotted into ParseRequest bridges Lua-set aliases to the worker
(Q#IJ4); included ranges are Vec<Range> excluding children and
intersected with the parent (Q#IJ5); the wire producer flattens layers
into disjoint effective spans because the GPU re-sorts by start, and
the GPU first-span consumer is fixed to fold (Q#IJ6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ9FQ832QwftJXCD9LeFan
This commit is contained in:
Levi Neuwirth 2026-07-15 09:41:48 +01:00
parent de50a516c5
commit 3edfa47281
1 changed files with 354 additions and 0 deletions

View File

@ -0,0 +1,354 @@
# Multi-language injections — framing (side quest, highlight family)
**Intent.** Teach the syntax engine that one buffer can hold more than
one language. Today pmacs parses a buffer with exactly one grammar and
runs exactly one `highlights.scm` over it ("first language wins"). This
adds tree-sitter *injection layers*: after the root parse, run the
grammar's `injections.scm` to find embedded regions (a markdown code
fence, an HTML `<script>`, a rust `macro!` body), parse each with the
injected language's grammar, and merge every layer's highlight spans.
It is the load-bearing enabler the side-quest backlog names: it unlocks
markdown fenced code, embedded languages, and is the honest gate on the
per-cell notebook path.
The first *consumer* that ships with the engine is **markdown fenced
code blocks plus inline markdown**, chosen because it needs **zero new
grammars**: the markdown block grammar is already bundled and already
ships an injection query, and every language a fence can name (rust,
python, bash, js, ts, go, toml, …) already has a grammar from the
grammar-gap PR (#118). The engine is grammar-agnostic; other injection
sites (HTML embedding, JS template literals, comment-embedded langs)
become follow-ups as their grammars land.
---
## Ground truth (as of `main` @ `0ba01fe`, #119)
The syntax stack assumes **one tree, one language, one query per
buffer** end to end. Every layer of that assumption has to grow.
- **`ParseView` / `ParseTreeBundle`** (`src/syntax.rs`) — the per-buffer
parse state holds a single `language`, source mirror, pending-`InputEdit`
list, and `current: Option<Arc<ParseTreeBundle>>`. `ParseTreeBundle`
holds one `tree`, one `source`, one `language_name`. `run_parse`
(`syntax.rs:106`) sets one language and returns one tree.
- **Highlight query**`SyntaxRegistry::highlights_query(name)`
(`syntax.rs:655`) lazily compiles and caches **one** `highlights.scm`
per language into the main-thread `Rc<SyntaxRegistry>`. `LanguageEntry`
(`syntax.rs:318`) carries `highlights_query: &'static [&'static str]`
but **no injection query**.
- **Two style producers, both single-tree:**
- Grid/TUI: `SyntaxHighlightView` (`src/highlight.rs:289`) is
constructed with **one** `Arc<Query>` and caches spans for the one
`parse.current()` bundle, invalidated by `Arc::ptr_eq`.
- Daemon→GPU wire: `scoped_style_spans` (`src/semantic_render.rs:1640`)
reads the one `bundle`, the one `highlights_query(language_name)`,
runs `compute_highlight_spans_in_range` scoped to the viewport, maps
captures→theme→`StyleSpan`. Perf-gated by `StyleGate`
(`semantic_render.rs:1612`) keyed on the bundle `Arc`.
- **The wire path re-sorts spans by start — producer order is NOT
preserved.** The GPU applies `StyleSpans` through
`replace_style_spans` (full, `pmacs-gpu/src/main.rs:4112` — collects
segments then `sort_by_key(range.start)`) and `merge_style_spans`
(incremental, `main.rs:4130` — clips, appends, re-sorts). So any
"emit root-then-depth order and rely on it downstream" scheme is dead
on arrival; overlaps must be resolved **before** the wire (Q#IJ6).
- **The two `StyleSpan` consumers also disagree on overlap:**
`SemanticModel::effective_style_at` (`src/semantic_client.rs:334`)
folds **every** covering span via `merge_styles`; the GPU
`source_color_at` (`pmacs-gpu/src/main.rs:6718`) returns the **first**
covering span's fg and stops. Both are addressed by Q#IJ6.
- **Policy A** — grammar-backed buffer ⇒ styled solely by tree-sitter;
else solely by LSP tokens; never both on the wire
(`semantic_render.rs:1641`).
- **Dispatch/settle** (`builtin/runtime/syntax.lua`) — one language per
buffer, pinned at first attach; the `_dispatch` wrapper seam is
`syntax.lua:26`. The async tick installs the settled bundle.
- **Worker discipline**`ParseRequest` is fully owned (`Send`); parsing
runs on a worker. The registry (`Rc`) and any Lua table are main-thread
only — neither highlight-query resolution nor Lua-set config can happen
on the worker (Q#IJ2, Q#IJ4).
**Confirmed tree-sitter mechanics this design rests on** (tree-sitter
0.26, verified against vendored sources + the tree-sitter injection
docs):
1. `Parser::set_included_ranges(&[Range])` restricts a parse to given
byte ranges of the **full source**; resulting node offsets stay
**absolute** into the full buffer — injected spans are already in
buffer coordinates. Ranges must be **sorted, non-overlapping,
non-empty** or the call returns `IncludedRangesError`.
2. `tree_sitter_md::INJECTION_QUERY_BLOCK` exists on the already-bundled
crate, using **both** forms: dynamic `(info_string (language)
@injection.language)` + `(code_fence_content) @injection.content`, and
static `((inline) @injection.content (#set! injection.language
"markdown_inline"))` (also `html`/`yaml`/`toml`).
3. **The generic injection contract excludes child ranges and intersects
with the parent.** Unless `#set! injection.include-children` is set,
the injected ranges are the content node's extent **minus its direct
children's ranges**, then **intersected with the parent layer's own
included ranges** so a nested injection cannot reintroduce bytes its
parent excluded (tree-sitter highlighting docs; the bundled markdown
convenience parser shows a markdown-specialized form of the child
split at `parser.rs:406-425`). This makes `markdown_inline` a genuine
multi-range case (Q#IJ5).
4. `LanguageEntry.loader` (`fn() -> Language`) and query-source `&'static
str` consts are `Send` and touch no grammar C-object until called — a
worker resolves injected languages **lazily** by indexing `&'static
BUILTIN_LANGUAGES`, preserving the M4.2 lazy-load invariant.
5. Injection-query availability is per-crate and inconsistently named
(rust `INJECTIONS_QUERY`; markdown `INJECTION_QUERY_BLOCK`; bash none)
— the same pattern `highlights_query` already absorbs as a `&'static
[&'static str]` slice.
---
## Decisions
### Q#IJ1 — Two node types: worker `RawLayer`, settled `Layer`; the layer set lives in `ParseTreeBundle`
```
RawLayer { language_name: String, tree: Tree, depth: u16 } // worker out
Layer { language_name: String, tree: Tree, depth: u16, // settled
highlight_query: Option<Arc<Query>> }
```
`ParseTreeBundle` grows to an ordered `Vec<Layer>` (root = layer 0,
whole buffer; children depth-ascending). One `Arc<ParseTreeBundle>` is
installed **atomically** per settle, so the `StyleGate` `Arc::ptr_eq`
gate and the `SyntaxHighlightView` cache stay **unchanged** — a layered
reparse mints a fresh Arc and flips both gates. `bundle.language_name`
stays (root label). Per-layer `included_ranges: Vec<Range>` (Q#IJ5) are
the worker's parse *input*, not retained on the settled `Layer` (styling
reads the tree; offsets are absolute).
### Q#IJ2 — Two-stage handoff: worker builds trees, settle resolves queries
- **Stage 1 — worker (`run_parse_layered`)** builds the whole layer
*structure*: root tree (incremental) + every child tree, via injection
queries and `set_included_ranges`. It resolves injected languages by
indexing `&'static BUILTIN_LANGUAGES` (loaders + a new `injections_query:
&'static [&'static str]` field on `LanguageEntry`, mirroring
`highlights_query`), loading/compiling only what a file injects.
Touches no highlight query, no theme. Output `Vec<RawLayer>`.
- **Stage 2 — settle/install (main thread)** resolves each raw layer's
`highlights_query(name)` from the registry cache, builds `Vec<Layer>`,
wraps one `ParseTreeBundle`, installs the `Arc` **atomically**.
Highlight-query compilation stays main-thread/cached/shared with the
producers; tree parsing stays on the worker. The only dynamic worker
input beyond the static table is the alias snapshot (Q#IJ4), carried in
`ParseRequest`.
*Rejected:* (a) layer-build on the main thread in settle — moves child
parsing onto the frame path; (b) a `Send + Sync` query store so the
worker resolves everything — larger registry blast radius, deferred as
an option if Stage 2 bottlenecks.
**Limitation named:** injection targets resolve only against
`BUILTIN_LANGUAGES`; runtime/Lua-registered languages are not injectable
in v1. Every headline case is bundled.
### Q#IJ3 — Bounded recursion: depth cap, generous layer backstop, visited guard, child-only failure
- **max depth** (default 3),
- **max total layer count** — a *runaway backstop* set well above any
real document (default **4096**), decoupled from performance; the real
perf bound is the Q#IJ10 settle-time guard. Hitting it is **logged, not
silent** (surfaced via `pmacs.error`/`*workers*`), and a modest markdown
doc's inline layers (one per paragraph/heading) sit far under it.
- a **visited set on `(language_name, ranges)`** so a same-language
self-injection over a non-shrinking region can't reproduce itself.
**Failure is isolated to the child:** unknown/unresolvable language, cap
hit, or child parse error drops **that child layer only** — never the
root, never a sibling. The root always installs.
### Q#IJ4 — Dynamic fence names: registry-held alias map, case-folded, snapshot to the worker
Raw `@injection.language` text (`JS`, `ts`, `sh`, `py`, `Rust`, `c++`,
`jsx`, `tsx`) won't exact-match bundled names — the job tree-sitter's
per-language injection-regex does. pmacs does it with a **case-folded
alias table**: lowercase the text, look up an alias table before the
bundled-name table (`js`→javascript, `jsx`→javascriptreact,
`ts`→typescript, `tsx`→typescriptreact, `py`→python, `rs`→rust,
`sh`/`shell`→bash, `c++`/`cxx`→cpp, `yml`→yaml, …). Unresolved → region
skipped (no error, root intact).
**Worker-safe extensibility:** the map lives in the registry (static
defaults + Lua-driven overrides via a Rust setter that
`pmacs.parse.injection_aliases` writes through). Because the worker can't
read Lua or the `Rc` registry, each `_dispatch` (the `syntax.lua:26`
seam) **snapshots the merged map into an `Arc<HashMap<String,String>>`
carried in `ParseRequest`**. Acceptance mutates the alias set from Lua,
then runs an **asynchronous** injected parse and asserts the new alias
resolves — proving the bridge, not just the static resolver.
### Q#IJ5 — Included ranges are `Vec<Range>` per layer: exclude children, intersect with parent
A layer's ranges are built from its `@injection.content` node(s):
- `#set! injection.include-children``[node.range]`;
- otherwise (default; the markdown-inline case) → the node's extent
**minus its direct children's ranges**, **then intersected with the
parent layer's included ranges** (mechanic #3). Ranges come out ordered
and non-overlapping; empty ranges dropped.
Fenced code (`code_fence_content`, no children) → one range; an inline
paragraph with a link and emphasis → several. Core, not deferred —
`markdown_inline` needs it. Acceptance parses an inline paragraph with a
link **and** emphasis, not just plain text.
### Q#IJ6 — The wire producer flattens layers into disjoint effective spans
Because the wire re-sorts by start (`main.rs:4117`/`4130`), producer
order cannot carry overlap precedence. So overlaps are resolved **in the
producer, before the wire**:
- **`scoped_style_spans` flattens** all layers over the viewport into
**disjoint effective spans** — a sweep-line that, at each byte, takes
the deepest layer covering it, and within a layer the existing
wider-first "narrower overrides" rule. Output is disjoint runs of a
single folded style. The viewport already bounds the sweep, so this is
O(boundaries) in the visible range. Positional re-sorting downstream is
then a no-op on precedence, and the disjoint output aligns with the
model's existing style-tile disjointness invariant. (Per-byte effective
style is unchanged from today for single-layer buffers; only the *shape*
goes overlapping→disjoint, so existing `StyleSpans` shape assertions are
updated to match.)
- **The GPU `source_color_at` fold fix is kept** (fold all covering spans
in order, matching `effective_style_at`) — defense-in-depth and a
contract alignment, correct even for any residual same-start overlap.
- The **grid** producer (`SyntaxHighlightView`) has no wire and no sort:
it paints layer-by-layer in depth order (later overrides via the cell
merge), which is already correct.
Acceptance test #9 drives the **full message-application path**
(`replace_style_spans` → render → `source_color_at`) with an overlapping
parent-red / child-green case, not a direct `source_color_at` call.
### Q#IJ7 — Both producers walk layers; Policy A unchanged at buffer scope
`scoped_style_spans` and `SyntaxHighlightView` iterate `bundle.layers`
using each `layer.highlight_query`, reusing viewport-scoped
`compute_highlight_spans_in_range` per layer. Policy A unchanged at the
buffer level (grammar-backed ⇒ tree-sitter across *all* layers; LSP-only
untouched; no new `LspStyleView` interaction). `SyntaxHighlightView`
drops its single-`query` constructor param and reads per-layer
`Arc<Query>` from the bundle, so it still holds only `Send` state — the
largest single code change.
### Q#IJ8 — Incrementality: root incremental, children cold each settle
Root keeps `InputEdit`-accumulation + `prior_tree`. Child layers rebuild
**cold** each settle (an edit can add/remove/resize regions, so child
identity isn't stable). **Deferred:** child-tree incrementality and
range-scoped rebuild. Named cost: one cold inline layer per markdown
paragraph/heading — made a measured acceptance guard by Q#IJ10, not a
hope.
### Q#IJ9 — `injection.combined` deferred
Each injection **match** is its own layer/parse (multi-range *within* a
match is Q#IJ5, in scope). Combined injections (many matches → one shared
parse; PHP-in-HTML, some comment schemes) are **deferred**. Markdown
fenced/inline are not combined.
### Q#IJ10 — First consumer: markdown fenced code **and** inline
Ships fenced-code **and** `markdown_inline` (zero new grammars; same
crate; block grammar already injects inline via `#set!`). Inline
exercises the static path and the multi-range path (Q#IJ5), completes
markdown, and retires the M9.7 "block-only" floor. Its cost is real (a
cold child parse per paragraph/heading), so a **many-paragraph
settle-time acceptance test** guards it **and asserts the final paragraph
receives a layer/capture** (not merely that parsing finished). While that
stays green, child incrementality (Q#IJ8) is not required in v1. HTML,
JS/TS template literals, and doc-comment code are follow-ups gated on
their grammars.
### Q#IJ11 — Perf gate & parse-in-flight stay correct for free
`StyleGate.bundle` is the root bundle Arc; a layered reparse installs a
fresh Arc atomically (single dispatch), flipping the gate with no
half-styled frame. `grammar_style_parse_not_ready` unchanged.
---
## Bets
1. Absolute node offsets under `set_included_ranges` make layer spans
buffer-coordinate-native (mechanic #1).
2. Static-table worker + two-stage settle + alias snapshot (Q#IJ2/IJ4)
preserves lazy loading, keeps parsing off the main thread, and keeps
query caching where it is.
3. Producer-side flattening (Q#IJ6) is the one definite overlap strategy
given the wire re-sort; the GPU fold fix rides along.
4. Cold child reparse is fast enough at real paragraph/fence counts; the
Q#IJ10 guard is the measured backstop, Q#IJ8 the escape hatch.
## Deferred (named)
- `injection.combined` (Q#IJ9).
- Child-tree incrementality + range-scoped rebuild (Q#IJ8), gated open by
the Q#IJ10 perf guard.
- Injectable runtime/Lua-registered languages (Q#IJ2).
- HTML/CSS/GraphQL/SQL grammars and their injection sites (Q#IJ10).
- Notebook per-cell layering (needs JSON grammar + this engine).
- `Send + Sync` query store so the worker resolves highlight queries
directly (Q#IJ2 alternative b).
## Acceptance (bite-verified where it guards a real gap)
1. `injection_query_block_compiles``INJECTION_QUERY_BLOCK` compiles
against the md grammar.
2. `layered_parse_builds_child_for_fenced_code` — a ` ```rust ` fence
yields ≥2 layers; child language == rust, roots at `source_file`.
3. `child_layer_offsets_are_absolute` — a `fn` inside the fence has byte
offsets matching its position in the **full** markdown source.
4. `dynamic_alias_resolves`` ```py `→python, ` ```JS `→javascript
(case-folded); ` ```nonsense ` → no child, no error, root intact.
5. `alias_override_from_lua_async` — mutate `injection_aliases` in Lua,
then an **async** injected parse resolves the new alias (Q#IJ4 bridge).
6. `inline_layer_multi_range_link_and_emphasis` — an inline paragraph
with a link **and** emphasis becomes a `markdown_inline` layer whose
ranges exclude the direct children and highlight correctly (Q#IJ5).
7. `recursion_bounds_hold` — depth cap, layer-count backstop, and
`(language, ranges)` visited guard each terminate; a failing child
drops only itself, root installs (Q#IJ3).
8. `wire_producer_emits_child_spans``scoped_style_spans` over a
` ```rust ` fence emits disjoint `StyleSpan`s covering a rust keyword
**inside** the fence. **Bite-verified** vs the single-layer producer.
9. `gpu_overlapping_child_color_wins` — parent-red / child-green overlap
applied through `replace_style_spans` then rendered: the child (green)
wins. **Bite-verified** vs the current first-span-wins path (Q#IJ6).
10. `grid_producer_paints_child_span``SyntaxHighlightView` paints a
rust-keyword cell inside the fence.
11. `incremental_edit_reflects_in_child` — editing inside a fence shows in
child spans after reparse; a **new** fence adds a layer (Q#IJ8).
12. `many_paragraph_settle_under_budget_tail_covered` — a large all-inline
markdown buffer settles within a comfortable budget **and the final
paragraph receives a layer/capture** (Q#IJ3 cap + Q#IJ10 guard).
13. `non_injecting_buffer_single_layer` — a plain `.rs` buffer still
yields exactly one layer (regression guard).
## Risks / interactions
- **Perf** (Q#IJ8/IJ10) — cold child reparse per settle, one inline layer
per paragraph; test 12 is the measured guard (with tail coverage),
Q#IJ8 the release valve.
- **Wire overlap** (Q#IJ6) — the wire re-sorts by start, so flattening in
the producer is mandatory; the GPU fold fix rides along. Tests 8/9 pin
both.
- **Single-layer wire shape** — flattening turns overlapping spans
disjoint for *all* grammar buffers; per-byte style is unchanged, but
existing `StyleSpans` shape assertions are updated.
- **`set_included_ranges` contract** — sorted, non-overlapping, non-empty;
the Q#IJ5 exclusion+intersection yields this by construction, but guard
empty inline nodes and non-UTF-8 info strings.
- **M9.7 prompt-result markdown buffers** now get fenced + inline
highlighting (a bonus, retiring the block-only floor); verify the
`_attach_highlight`-for-markdown path doesn't crash.
- **Themes main quest** — untouched. Highlight *structure*, not color;
capture→style still flows through `Theme::lookup`. No protocol bump
(`StyleSpan` wire shape unchanged).