Multibuffer Per-Excerpt Syntax Highlighting (K.4.7)
Status: implemented (2026-06-08); source reparse + wake corrected 2026-09-05
Slice plan: docs/dev/operations/slice-plans/archive/multibuffer-is-a-regular-buffer.md, slice K.4.7
Problem
A multibuffer view composes N source files into one virtual document. The composed snapshot carries the view's name (*search:foo*) — so Lang::detect_from_path always returns Lang::Plain, and every row renders unstyled regardless of source language. The highlight path the cells worker uses (a single SyntaxHandle per pane) cannot express "row 0–4 are Rust, row 5–9 are Markdown".
Options considered
Option B — one-shot, per-pane full-file re-parse. Build a scratch Syntax from the entire composed text on each pane publish. Rejected: O(file) parse per render cycle violates paramount-goal #1 (no O(file) work in the hot path); also loses the incremental-reparse benefit because the composed text changes identity whenever any source edits.
Option C — register source docs in BufferRegistry. The source docs in a multibuffer are only in MultibufferState.sources, not in BufferRegistry, so no existing SyntaxHandle exists for them. Adding them to the registry would pollute :ls, :bn, :bp, and mode-activation with sources the user never opened directly — violating the "everything is a buffer" contract.
Chosen design: Option A — per-source long-lived SyntaxHandle
Each source document in MultibufferState.sources that has a detectable language gets its own SyntaxHandle. Handles are created eagerly:
- At
add_sourcetime iflang_registryis already wired. - At
set_lang_registrytime retroactively for sources already in state (the common production path:new(sources, …)is called first, then the host callsset_lang_registryaftercreate_multibuffer_view).
Handles are built by MultibufferDocumentHandle::seed_source_syntax — one constructor, used by both creation sites, because what gets forgotten is the wake rather than the parse.
Staying current (corrected 2026-09-05)
A handle that is only ever created is frozen. The original wiring used SyntaxHandle::seeded, which passes on_publish: None, and nothing anywhere called request_reparse on a source handle — so each source parsed once at creation and its snapshot never moved again. The section below used to claim that "a per-source background reparse invalidates the cells cache"; no such reparse existed. The user-visible result was a task toggled to DONE in the agenda still painting in TODO's colour, forever: the DocumentChanged arm recomposed the text beside it, so the symptom read as "new text, old colours" rather than "nothing updates", and <C-l> did not help either because re-rendering re-read the same frozen snapshot.
Two halves, and both are required:
A trigger.
reparse_source_syntaxruns in theDocumentChangedarm, besiderecompose_inner— a source edit makes the composed text and the source's spans stale, and recomposing only the first is the bug. It passes emptyedits, i.e. a full reparse: the incremental path would need the event'sAppliedEdits translated into tree-sitter deltas against the version the cached tree is actually at, and a delta applied to the wrong baseline corrupts the tree silently — the same "wrong until something forces a full parse" failure, one layer down.The trigger is only as good as what reaches it. A
DocumentChangedarrives for a source edited from outside the view. An edit made through the view — the composed path an agendaDONEtoggle takes, since org'srewrite_headlinetargets the view at composed coordinates — used to reach the source through the forwarder, which applied it and published nothing. See "Announcing a source change" below.A wake.
seed_source_syntaxpasses anon_publishthat bumpsexcerpt_syntax_genand publishesMultibufferExcerptsReady. The bump is what the cells worker invalidates on; the event is whatinstall'swake_on_eventturns intoasync_landed, so a reparse landing while the user reads their agenda reaches the screen with no keypress to hide behind. Without (1) nothing reparses; without (2) a correct new snapshot sits unread.
This mirrors how the host wires a regular document's handle (seeded_with_runtime plus a wake that fires async_landed and an invalidation event) rather than being a second mechanism.
Announcing a source change (2026-09-05)
A source that changes without an event is one every subscriber has stale. The source-forwarder applied composed edits to their sources and published nothing — "the multibuffer's local composed_doc is already authoritative", which is true of the composed rope and says nothing about everyone else. Nothing learned the file had changed: not this view's syntax, not a second view on the same source, not lattice-diff, not a plugin subscribed to DocumentChanged.
The forwarder now publishes Event::DocumentChanged after a successful apply — on success only, since a failed apply left the source untouched and announcing it would have every subscriber recompute against content that never existed. DirectEdit deliberately does NOT publish: its caller is the host's apply_edit_to_multibuffer_source, which already does, and two publishes for one edit is worse than none.
The view then hears its own edit come back, and must not treat the echo as an outside change: slide_anchors_for_source would shift every excerpt below the edited row a second time, for an edit the composed edit already accounted for. self_forwarded_versions records the source text_version the forwarder produced, written BEFORE the publish (the subscription runs on another task, and the publish is what wakes it — recording afterwards is a race the view loses by sliding its own anchors). On an echo the view skips slide + recompose and still reparses; the reparse is the whole point, and the echo is the case that carries it.
Matching on the version rather than adding a provenance field to Event::DocumentChanged: the version is already carried, already unique per mutation, and keeps the question inside the crate that has it instead of in the protocol for every subscriber that does not care.
Data model
// MultibufferState (lattice-multibuffer/src/lib.rs)
source_syntax: HashMap<BufferId, Arc<SyntaxHandle>>
// MultibufferInner
lang_registry: OnceLock<Arc<LangRegistry>>
Cells-side contract
dispatch.rs::build_cells_panes reads the multibuffer registry per pane:
let excerpt_syntax: Arc<[ExcerptSyntax]> = services
.get::<MultibufferRegistryHandle>()
.and_then(|r| r.handle(buffer_id))
.map(|mb| mb.excerpt_syntax_entries()
.into_iter()
.map(|(cs, ce, ss, h)| ExcerptSyntax { composed_start: cs,
composed_end: ce,
source_start: ss,
handle: h })
.collect::<Vec<_>>().into_boxed_slice().into())
.unwrap_or_else(|| Arc::from([]));
The syntax axis of MatrixVersion folds in excerpt_syntax_version() — the monotonic excerpt_syntax_gen counter — so a per-source reparse invalidates the cells cache and triggers a rebuild.
Not an XOR of the handles' versions, which is what this said until 2026-09-05 and what the code briefly did: N handles all at version 1 XOR to 0 for even N, colliding with the initial zero and producing a false cache hit that freezes highlighting. A monotonic counter cannot collide with itself.
Worker highlight path
cells_worker::highlight_range_multibuffer assembles per-excerpt spans:
fn highlight_range_multibuffer(
excerpt_syntax: &[ExcerptSyntax],
lo: u32, // composed row, inclusive
hi: u32, // composed row, exclusive
) -> Option<Vec<Vec<StyledSpan>>>
Returns None when excerpt_syntax is empty (single-document pane falls through to the existing syntax_handle path). For each excerpt that overlaps [lo, hi), translates composed-row coordinates to source-row coordinates, calls snap.highlight_lines(src_lo, src_hi), and writes spans into the result indexed relative to lo. Rows not covered by any excerpt keep an empty span list (plain-text fallback — correct, because separator/header virtual rows carry no source content).
recompute_pane's highlight_range closure checks excerpt_syntax first:
if let Some(spans) = highlight_range_multibuffer(&pane.excerpt_syntax, lo, hi) {
return Some(spans);
}
// single-document path ...
ExcerptSyntax struct (render_state.rs)
pub struct ExcerptSyntax {
pub composed_start: u32, // inclusive, composed-space
pub composed_end: u32, // inclusive, composed-space
pub source_start: u32, // first source row mapped to composed_start
pub handle: Arc<SyntaxHandle>,
}
Non-multibuffer panes carry excerpt_syntax: Arc::from([]) (zero-cost empty slice).
Paramount-goal alignment
| Goal | Impact |
|---|---|
| #1 Performance | O(viewport) highlight remains: highlight_range_multibuffer clips per-excerpt to [lo, hi). No O(file) work on the hot path. |
| #2 Extensibility | Provider-contributed sources (search, LSP references) automatically get highlights when the host passes lang_registry. No per-provider code. |
| #3 Modal editing | Unchanged. |
| #4 Asynchronicity | SyntaxHandle workers run on the tokio runtime; snapshot() is wait-free ArcSwap read. |
UX contract
Per the feedback_decorations_update_in_place standing rule: rows keep their existing colours during an async reparse (the stale snapshot is still coloured); only the edited row loses colour momentarily if its excerpt's version falls behind. This is the same behaviour as single-document panes — no new regression.