388 lines
21 KiB
Markdown
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).
|