pmacs/docs/multi-language-injections-f...

388 lines
21 KiB
Markdown

# 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 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 NAMED children's
ranges**, then **intersected with the parent layer's own included
ranges** so a nested injection cannot reintroduce bytes its parent
excluded. *(Implementation note, round 1: excluding ALL children — not
just named — shreds a markdown block `inline` node, whose children are
anonymous text tokens, into unparseable fragments. Excluding only named
children matches `tree-sitter-md`'s own inline splitter,
`parser.rs:406-425`, and is correct for every real injection site.)*
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 **surfaced, not
silent**: `run_parse` sets a `ParseTreeBundle::injection_capped` flag,
and `syntax.lua`'s settle tick raises it once per buffer via
`pmacs.error` (`_injection_capped`). A modest markdown doc's inline
layers (one per paragraph/heading) sit far under the backstop. A real
boundary test drives just over 4096 fences and asserts the flag + capped
count + intact root.
- 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 NAMED children's ranges** (anonymous token children are the
injected text itself and are kept — mechanic #3), **then intersected
with the parent layer's included ranges**. Ranges come out ordered and
non-overlapping; empty ranges dropped.
Fenced code (`code_fence_content`, no children) → one range; a **multi-line**
container (a blockquote/list whose inline node carries a named
`block_continuation` child) → several. Core, not deferred —
`markdown_inline` needs it. Acceptance asserts `content_node_ranges`
returns **>1 range** for a multi-line blockquote and that both sides parse
and highlight (a one-line paragraph would give a single range and could
not falsify multi-range support).
### 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 into **disjoint effective
spans** — an ordered active-set **event sweep**: activate a span at its
start boundary, expire it at its end, and at each interval fold the
active set (deeper layer / later same-depth sibling / narrower capture
wins, keyed by `(layer_index, capture_order)`). Cost is
**O(n·log n + Σ active)** in the span count — linear in practice, since
the active set is bounded by overlap depth, not the total span count.
This bound matters because the file-style summary runs the flattener
over the **whole buffer**, not just the viewport. 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 real full-frame message transform
(`spans_from_segments`, extracted from `replace_style_spans`) then
`source_color_at`, with an overlapping parent-red / child-green case —
exercising the start-sort + fold, not a hand-rolled sort. (A live-`State`
render pass needs a GPU device and is out of unit-test scope; the extracted
transform is the code that matters here.)
### 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. `lua_alias_override_resolves_on_async_parse` — mutate
`injection_aliases` in Lua, then an **async** injected parse resolves
the new alias (Q#IJ4 bridge). Plus `sync_parse_now_resolves_alias`
(round 1): the same must hold on the **synchronous** `_parse_now` path.
6. `inline_layer_multi_range_excludes_block_continuation` — a **multi-line**
blockquote's inline node carries a named `block_continuation`;
`content_node_ranges` returns **>1 range** and both sides parse +
highlight (Q#IJ5). (A one-line paragraph gives a single range and can't
falsify multi-range.)
7. `recursion_bounds_terminate` — rust macro self-injection terminates
within the depth bound; `injection_layer_cap_surfaces_and_preserves_root`
drives >4096 fences (bundle-flag level), and
`injection_cap_surfaced_once_and_rearms_via_lua` (round 2) drives the
**observable** Lua settle path: `pmacs.error` once, suppressed on an
unchanged re-parse, and re-armed after dropping below the cap and
exceeding it again. A failing child drops only itself (Q#IJ3).
8. `wire_producer_emits_disjoint_child_spans_in_fence``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. `flatten_same_depth_sibling_later_layer_wins` (round 2): a
later same-depth sibling layer wins the fold, matching grid paint order.
`full_buffer_summary_flatten_scales_on_large_grammar_file` (round 1):
the whole-buffer **flatten** stays ~linear under the event sweep (a
quadratic flatten regresses it; the summary's per-line tally is a
separate pre-existing loop).
9. `source_color_folds_overlapping_child_over_parent` — parent-red /
child-green overlap driven through the real `spans_from_segments`
(`replace_style_spans` body): the child (green) wins the fold.
**Bite-verified** vs the first-span-wins path (Q#IJ6).
10. `grid_paints_injected_child_keyword``SyntaxHighlightView` paints a
rust-keyword cell inside the fence.
11. `incremental_edit_reflects_in_child_and_new_fence_adds_layer` — editing
inside a fence shows in child spans after reparse; a **new** fence adds
a layer (Q#IJ8).
12. `many_paragraph_settle_under_budget_with_tail_covered` — a large
all-inline markdown buffer settles within a comfortable budget **and
the final paragraph receives an inline layer** (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).