// semantic_render.rs --- Instance-side semantic projection (T M11.2). //! The semantic projection seam. //! //! [`crate::instance_render::RenderState`] rasterizes the editor to a //! cell grid and ships [`InstanceMessage::CellDelta`]. `SemanticRenderState` //! is its sibling for `semantic_render` sessions: it reads the same //! [`EditorState`] but exits the pipeline *earlier* — it emits the //! structured byte-range styling the cell painter would otherwise have //! consumed, mapped through the active [`crate::highlight::Theme`], //! without the grid-packing step. Styling has one authority per //! language (policy A): tree-sitter spans from [`crate::syntax`] for //! grammar-backed languages, LSP semantic tokens //! ([`crate::lsp::LspManager::semantic_style_context`]) for languages //! with no bundled grammar (C/C++, …). The frontend lays the styling //! out locally over rope text it already holds via its `crdt_replica` //! `BufferMirror`. //! //! Contract boundary (see `docs/semantic-frontend-protocol.md`): the //! instance never learns a pixel. The only spatial fact it consumes is //! the buffer byte range the frontend declared on screen via //! [`crate::protocol::FrontendEvent::Viewport`]; styling is scoped to //! that range so a 100k-line file's styling is never shipped wholesale. //! //! Produced families: `StyleSpans` (M11.2; dual authority per above) //! and `Decorations` (M11.3), both span-granularity diffed (M11.4); //! `InlineAdornments` (Step 3, from the LSP inlay-hint store, //! M11.2-level suppression); `FileStyleSummary` (resolving Open Q#2 — //! per-line dominant style for a minimap, generation-keyed). //! `BlockAdornments` / `FoldState` / `ResourceOffer` remain wire- //! declared but unproduced. use std::collections::HashMap; use crate::buffer::BufferId; use crate::cell::Style; use crate::editor::EditorState; use crate::protocol::{ AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, StyleSegment, StyleSpan, }; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] struct DeclaredViewport { buffer_id: BufferId, visible: ByteRange, /// The CRDT generation the frontend computed `visible` against. /// Recorded for the M11.4 "ignore a viewport that races a /// not-yet-applied edit" refinement; M11.2 always honors the most /// recent declaration verbatim. frontend_generation: u64, } /// The diff baseline for one family on one buffer: the /// declared-viewport region the set was computed for, the full /// scoped item set last shipped, and the CRDT generation that set /// was computed against. The next frame diffs against `items`; /// `visible` changing (or no entry) forces a `full` resync, and /// `generation` changing also forces a `full` — see T M11.7. /// /// **T M11.7 — `generation`-tracked full-resync.** Without this, /// edits broke the consumer's incremental-update contract: /// `changed_intervals` only ships dirty-range items on `full=false` /// frames, but after a text-shift the frontend's cached spans /// (indexed by *pre-edit* byte positions) need to be replaced /// wholesale — the post-edit positions have shifted under them. /// Forcing `full=true` on every generation transition makes the /// next emission a `replace_*` on the frontend side, which is the /// correct behavior. Cost: one extra full-viewport ship per edit; /// negligible on a local Unix socket and bounded by the viewport /// size. struct LastFrame { visible: ByteRange, items: Vec, generation: u64, } /// Owns one `semantic_render` session's projection state: the last /// viewport the frontend declared, and the diff baseline per buffer /// for the `StyleSpans` and `Decorations` families. pub struct SemanticRenderState { /// The session this projection serves. Selection is per-window /// (per-frontend) state, so the decoration projection needs the /// fid to resolve *this* session's active window via /// `active_window_for`. Styling and diagnostics are per-buffer and /// do not consult it. frontend_id: FrontendId, /// `None` until the frontend's first [`Self::set_viewport`]. While /// `None`, [`Self::render_frame`] emits nothing: the frontend /// bootstraps its rope from `BufferSnapshot`, declares what is on /// screen, and only then receives styling for exactly that range. viewport: Option, /// Styling diff baseline, keyed by buffer (T M11.4). An unchanged /// frame ships nothing; a changed frame ships only the dirty /// byte-range segments. last_sent: HashMap>, /// Decorations diff baseline, tracked independently of `last_sent` /// so a styling change does not force a decorations re-send and /// vice versa. last_decorations: HashMap>, /// `InlineAdornments` baseline (T M11 producer arc, Step 3). The /// wire variant carries no `generation`/`full`/`segments`, so /// unlike the two families above this is only M11.2-level /// suppression: a whole-set re-send on any change, nothing when /// byte-identical. `LastFrame::items` reuse keeps the shape /// uniform even though no segment diffing applies. last_adornments: HashMap>, /// `FileStyleSummary` baseline (post-M11 minimap producer, /// resolving design-note Open Q#2). The whole-file dominant-style /// summary is expensive to compute on a 100k-line file, so the /// producer short-circuits on the last sent CRDT generation: a /// buffer at the same generation re-uses what the frontend /// already has and emits nothing. First emission happens on the /// first frame for a buffer; further emissions only after edits. /// `(crdt_generation, diag_epoch)` the last emitted summary was /// computed against. Diagnostics arrive without a generation /// bump, so the epoch half catches republishes (minimap marks, /// T M4.6 GPU parity). last_summary: HashMap, /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// the tree-sitter highlights query over the *whole declared /// viewport* (which the GPU frontend sets to the entire buffer) /// and clones the theme — too expensive to repeat on every tick. /// The styling depends only on the parse bundle, the CRDT /// generation, and the viewport — never the cursor — so a gate /// built from those lets cursor-only ticks skip the query entirely. /// Only the grammar (tree-sitter) path is gated; the LSP-token path /// has no comparably cheap handle and recomputes as before. last_style_gate: HashMap, /// Cached byte↔line table for the diagnostics projection, keyed /// by buffer revision. Building it costs an O(buffer) rope copy /// plus a full scan; before this cache, that ran on *every tick* /// while diagnostics were on screen (the table is only consulted /// when the store is non-stale and non-empty) — a steady-state /// CPU burn for a value that changes only when the buffer does. diag_line_cache: HashMap, } /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start /// offsets and source length of a buffer at `revision`. struct DiagLineCache { revision: u64, line_starts: Vec, source_len: u64, } /// Recompute gate for [`scoped_style_spans`] on a grammar-backed /// buffer. Holds the current parse bundle `Arc` so its address stays /// stable while cached — comparing by `Arc::ptr_eq` then can't be /// fooled by a freed bundle's address being reused (ABA). Equal gates /// ⇒ identical spans ⇒ the tree-sitter query can be skipped. /// `generation` is included so a CRDT edit still forces the M11.7 /// full-resync even when the parse bundle hasn't re-landed yet. #[derive(Clone)] struct StyleGate { /// Current parse bundle, or `None` when none has landed yet. bundle: Option>, /// CRDT generation of the buffer. generation: u64, /// Declared viewport. visible: ByteRange, } impl StyleGate { /// True when both gates would produce identical style spans. fn matches(&self, other: &Self) -> bool { self.generation == other.generation && self.visible == other.visible && match (&self.bundle, &other.bundle) { (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b), (None, None) => true, _ => false, } } } impl SemanticRenderState { /// Fresh session state for frontend `frontend_id`: no viewport /// declared, nothing sent. #[must_use] pub fn new(frontend_id: FrontendId) -> Self { Self { frontend_id, viewport: None, last_sent: HashMap::new(), last_decorations: HashMap::new(), last_adornments: HashMap::new(), last_summary: HashMap::new(), last_style_gate: HashMap::new(), diag_line_cache: HashMap::new(), } } /// Record the frontend's declared on-screen byte range. Called by /// the dispatcher when it receives /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any /// prior declaration wholesale — the latest viewport wins. pub fn set_viewport(&mut self, buffer_id: BufferId, visible: ByteRange, generation: u64) { self.viewport = Some(DeclaredViewport { buffer_id, visible, frontend_generation: generation, }); } /// Project one frame. /// /// Returns up to three messages — [`InstanceMessage::StyleSpans`] /// (T M11.2), [`InstanceMessage::Decorations`] (T M11.3), and /// [`InstanceMessage::InlineAdornments`] (Step 3, from the LSP /// inlay-hint store) — each scoped to the declared viewport and /// each suppressed independently when byte-identical to its last /// send. Returns an empty vec before the frontend declares a /// viewport. /// /// `BlockAdornments` / `FoldState` are still deliberately *not* /// produced: pmacs has no instance-side blame / lens / fold / diff /// source yet. Their wire variants exist (T M11.1); their /// producers wire in when those features land — the same /// "declared, not yet wired" discipline. Emitting an empty message /// every frame would be waste, not honesty, so `InlineAdornments` is /// suppressed both when unchanged and when there is simply nothing /// to say (no hints, no prior non-empty send). #[allow(clippy::too_many_lines)] pub fn render_frame(&mut self, state: &EditorState) -> Vec { let Some(vp) = self.viewport.clone() else { // Emit nothing before the frontend declares a viewport. return Vec::new(); }; let generation = buffer_generation(state, vp.buffer_id); let mut out = Vec::new(); // --- StyleSpans (T M11.2 producer, T M11.4 diff) --- // Perf gate: `scoped_style_spans` runs the tree-sitter query // over the whole viewport + clones the theme. For a grammar- // backed buffer it's a pure function of (bundle revision, // generation, viewport), so a cursor-only tick — same key, // already-sent baseline — can skip the whole block. The LSP- // token path returns `None` (no cheap revision) and recomputes // every tick as before. let style_parse_not_ready = grammar_style_parse_not_ready(state, vp.buffer_id); // The LSP-token styling authority (grammar-less buffers, e.g. // C++) gets the same hold: while the semantic-token store is // stale (document edited since the last token response), // `lsp_scoped_style_spans` would compute an empty set, and // shipping that clears the frontend's colors for the whole // stale window — the styling twin of the diagnostics blink. let style_tokens_stale = lsp_style_tokens_stale(state, vp.buffer_id); let style_hold = style_parse_not_ready || style_tokens_stale; let style_gate = (!style_hold).then(|| grammar_style_key(state, &vp, generation)); let style_gate = style_gate.flatten(); let style_unchanged = match (&style_gate, self.last_style_gate.get(&vp.buffer_id)) { (Some(g), Some(prev)) => g.matches(prev) && self.last_sent.contains_key(&vp.buffer_id), _ => false, }; if style_hold || style_unchanged { // If the style key is unchanged, styling cannot have // changed since the last computation. If a grammar parse is // still pending (or the LSP token store is stale), keep the // previous spans briefly rather than querying and reshaping // stale syntax on every typed byte; the parse-bundle // revision (or the next token response) will force a fresh // frame as soon as it settles. } else { match style_gate { Some(g) => { self.last_style_gate.insert(vp.buffer_id, g); } None => { self.last_style_gate.remove(&vp.buffer_id); } } self.emit_style_spans(state, &vp, generation, &mut out); } // --- Decorations (T M11.3 producer, T M11.4 diff) --- let mut decorations = self.scoped_decorations(state, &vp); let prev = self.last_decorations.get(&vp.buffer_id); // Hold-while-stale, part 2 (selection navigation): while the // diag store is stale, CARRY the previously shipped diagnostic // items through this frame's set instead of dropping them. // A shift+arrow during the post-burst stale window then diffs // as a tiny selection-only segment (the carried diag ranges // are unchanged, so they fall outside the changed intervals // and are never re-shipped at stale positions) — instead of // a full frame per keypress that also blinked the frontend's // held diagnostics out. let diag_hold = diagnostics_store_stale(state, vp.buffer_id); if diag_hold && let Some(p) = prev { decorations.extend( p.items .iter() .filter(|d| is_diagnostic_kind(d.kind)) .cloned(), ); decorations.sort_by_key(|d| d.range.start); } // Hold-while-stale: while the diag store is stale (document // edited since the last `publishDiagnostics`), this frame has // no authoritative diagnostic positions. The frontend's // last-received set — which it translates through its own // local edits — is strictly better than anything we can ship: // an empty frame wipes it (diagnostics blink out on the first // keystroke of every burst and back in after the next publish, // one full frontend reshape each way), and re-shipping the // store's items would anchor pre-edit positions over post-edit // text (the M11.8 artifact). So as long as the // *non-diagnostic* part is unchanged, say nothing and leave // the baseline untouched — staleness clears on the next // publishDiagnostics absorption, and the generation transition // since the held baseline forces that frame full. let held = diag_hold && prev .is_some_and(|p| p.visible == vp.visible && decorations.iter().eq(p.items.iter())); // During a hold, only the GENERATION trigger for a full frame // is suppressed (edits bump it every keystroke; the carried // set diffs instead). First-ever frames and viewport changes // still resync in full. let full = prev .is_none_or(|p| p.visible != vp.visible || (!diag_hold && p.generation != generation)); if held { // No new information for the frontend this frame. Keep // the baseline's generation current so the eventual // unstale frame diffs instead of full-resyncing (the diff // covers the diagnostics' post-publish positions). if let Some(p) = self.last_decorations.get_mut(&vp.buffer_id) { p.generation = generation; } } else if full { let suppress_empty_generation_bump = prev.is_some_and(|p| { p.visible == vp.visible && p.items.is_empty() && decorations.is_empty() }); self.last_decorations.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: decorations.clone(), generation, }, ); if !suppress_empty_generation_bump { out.push(InstanceMessage::Decorations { buffer_id: vp.buffer_id, generation, full: true, segments: vec![DecorationSegment { range: vp.visible, decorations, }], }); } } else { let prev = prev.expect("checked is_none_or above"); let intervals = changed_intervals(&prev.items, &decorations, |d| d.range); if !intervals.is_empty() { let segments = intervals .into_iter() .map(|range| DecorationSegment { range, decorations: clip_decorations(range, &decorations), }) .collect(); self.last_decorations.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: decorations, generation, }, ); out.push(InstanceMessage::Decorations { buffer_id: vp.buffer_id, generation, full: false, segments, }); } } // --- InlineAdornments (Step 3 producer) --- out.extend(self.inline_adornments_msg(state, &vp)); // --- FileStyleSummary (minimap producer; Open Q#2) --- out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); out } /// The `InlineAdornments` message for this frame, or `None` when /// nothing should be sent. The wire variant has no /// `generation`/`full`/`segments`, so this is M11.2-level /// suppression only: the whole scoped set re-sends on any change, /// nothing when byte-identical, and never an empty frame when /// there is simply nothing to say. Updates the baseline on send. fn inline_adornments_msg( &mut self, state: &EditorState, vp: &DeclaredViewport, ) -> Option { // Hold-while-stale — mirrors the Decorations hold in // `render_frame`. An empty frame here wipes the frontend's // cached virtual text mid-typing-burst, and inline adornments // occupy layout space: the wipe visibly shifts real glyphs // (and forces a reshape), then the post-refresh re-emit // shifts them back. The frontend's locally-translated cache // is the better picture until a fresh `inlayHint` response // clears the stale flag and re-emits through the diff below. if inlay_store_stale(state, vp.buffer_id) { return None; } let adornments = scoped_inline_adornments(state, vp); let should_emit = match self.last_adornments.get(&vp.buffer_id) { // First sight of this buffer: speak only if there is // something to show — no empty-frame spam. None => !adornments.is_empty(), // Re-send on a real change. `empty → empty` conveys // nothing and is suppressed; `non-empty → empty` *is* a // change worth sending so the frontend clears its overlay. Some(p) => { (p.visible != vp.visible || p.items != adornments) && !(adornments.is_empty() && p.items.is_empty()) } }; if !should_emit { return None; } // Adornments use the same `LastFrame` struct as // StyleSpans / Decorations, so we populate `generation` for // consistency. Adornments' diff predicate doesn't consult // it — the whole-set comparison at line 310 catches post- // edit position shifts directly — but tracking it keeps // the struct shape uniform. let generation = buffer_generation(state, vp.buffer_id); self.last_adornments.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: adornments.clone(), generation, }, ); Some(InstanceMessage::InlineAdornments { buffer_id: vp.buffer_id, items: adornments, }) } /// The `FileStyleSummary` message for this frame, or `None`. The /// summary is keyed on CRDT `generation`: a buffer with an /// unchanged generation re-uses the cached summary and emits /// nothing. The first frame for a buffer always emits (the /// frontend needs the baseline). Updates the baseline on send. fn file_style_summary_msg( &mut self, state: &EditorState, buffer_id: BufferId, generation: u64, ) -> Option { // The summary is a *whole-file* tree-sitter pass (the minimap // needs every line). Recomputing it on every edit's generation // bump was a per-keystroke O(file) cost — a major part of the // typing slowness. For grammar-backed buffers, debounce it to // reparse-completion. `pending_edit_count()` alone is not // enough: dispatch drains that list immediately, leaving the // expensive summary path free to run while a parse job is still // in flight. Wait until there is an installed parse, no pending // edits, and no recorded parse job for this buffer. if grammar_style_parse_not_ready(state, buffer_id) { return None; } // Diagnostics fold into the summary (minimap marks) but // publish without a generation bump — key the cache on the // diag store's per-URI epoch as well, so a republish // refreshes the marks and anything else stays suppressed. let diag_epoch = diagnostics_epoch(state, buffer_id); if self.last_summary.get(&buffer_id).copied() == Some((generation, diag_epoch)) { return None; } let lines = scoped_file_summary(state, buffer_id); self.last_summary .insert(buffer_id, (generation, diag_epoch)); Some(InstanceMessage::FileStyleSummary { buffer_id, generation, lines, }) } /// Project the [`Decoration`] set intersecting the declared /// viewport: the session's selection (instance-authoritative, /// byte-native) and LSP diagnostics (line/col → byte, severity → /// kind). Search-hit and current-line decorations are /// deliberately absent: pmacs has no instance-side search-hit /// store, and current-line is a pure cursor derivation the /// frontend already owns (it has `CursorByte`) — emitting it would /// couple a visual-motion concern to the instance, against the /// contract boundary. /// Compute the scoped style spans and push a `StyleSpans` message /// (full resync or M11.4 incremental) when they differ from the /// last sent baseline. Extracted from `render_frame` so the perf /// gate there can skip it wholesale on unchanged ticks. fn emit_style_spans( &mut self, state: &EditorState, vp: &DeclaredViewport, generation: u64, out: &mut Vec, ) { let spans = scoped_style_spans(state, vp); let prev = self.last_sent.get(&vp.buffer_id); // Resync when there is no baseline, the declared viewport // region moved (scoping window changed), OR the CRDT // generation advanced (T M11.7: text edits shift byte // positions, so prior spans are stale and incremental // updates can't restore the full viewport). let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation); if full { self.last_sent.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: spans.clone(), generation, }, ); out.push(InstanceMessage::StyleSpans { buffer_id: vp.buffer_id, generation, full: true, segments: vec![StyleSegment { range: vp.visible, spans, }], }); } else { let prev = prev.expect("checked is_none_or above"); let intervals = changed_intervals(&prev.items, &spans, |s| s.range); if !intervals.is_empty() { let segments = intervals .into_iter() .map(|range| StyleSegment { range, spans: clip_style_spans(range, &spans), }) .collect(); self.last_sent.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: spans, generation, }, ); out.push(InstanceMessage::StyleSpans { buffer_id: vp.buffer_id, generation, full: false, segments, }); } // No dirty interval → styling unchanged → emit nothing. } } fn scoped_decorations( &mut self, state: &EditorState, vp: &DeclaredViewport, ) -> Vec { let core = state.core.borrow(); let registry = core.registry.clone(); let reg = registry.borrow(); let mut out = Vec::new(); // Selection is per-window (per-frontend) state. CurrentLine is // deliberately not emitted for semantic frontends: the GPU has // CursorByte and paints its own caret/current-line affordances. // Emitting CurrentLine here forced a whole-buffer line table on // every frame even though pmacs-gpu ignores its own current-line // wash. if let Some(win) = core.active_window_for(self.frontend_id) && win.buffer_id == vp.buffer_id && let Some((lo, hi)) = win.region() && let Some(range) = clip_to_viewport(lo, hi, vp) { out.push(Decoration { range, kind: DecorationKind::Selection, }); } // Diagnostics — keyed in the shared store by the file URI the // Lua LSP glue opened the document under. The URI is derived // from `vp.buffer_id` (see [`buffer_file_uri`]'s docs for why // not from `core.active_buffer_path()`). // // T M11.8 — skip emission while the store is stale (the // document has been edited since the last `publishDiagnostics` // absorption). Without this, the producer ships diagnostics // whose byte positions point at pre-edit text — the visible // wrong-position color artifact session-5 validation surfaced. // The LSP layer's `did_change_full` marks the URI stale; the // next `publishDiagnostics` absorbs and clears the flag. if let Some(uri) = buffer_file_uri(&core, vp.buffer_id) { let (diags, is_stale) = { let store = state.lsp_manager.borrow().diag_store(); let guard = store.lock().expect("diag store mutex poisoned"); (guard.for_uri(&uri).to_vec(), guard.is_stale(&uri)) }; if !is_stale && !diags.is_empty() && let Ok(buf) = reg.get(vp.buffer_id) { // Byte<->line mapping, cached per buffer revision — // rebuilding it is an O(buffer) rope copy + scan, far // too expensive to repeat on every tick a diagnostic // is on screen. let cache = self .diag_line_cache .entry(vp.buffer_id) .and_modify(|c| { if c.revision != buf.revision() { let s = buffer_source_bytes(buf); c.revision = buf.revision(); c.line_starts = line_start_offsets(&s); c.source_len = s.len() as u64; } }) .or_insert_with(|| { let s = buffer_source_bytes(buf); DiagLineCache { revision: buf.revision(), line_starts: line_start_offsets(&s), source_len: s.len() as u64, } }); let (line_starts, source_len) = (&cache.line_starts, cache.source_len); for d in &diags { let lo = line_col_to_byte(line_starts, source_len, d.start_line, d.start_col); let hi = line_col_to_byte(line_starts, source_len, d.end_line, d.end_col); if let Some(range) = clip_to_viewport(lo, hi, vp) { out.push(Decoration { range, kind: severity_to_kind(d.severity), }); } } } } out } } /// Project the LSP inlay-hint set intersecting the declared viewport /// into [`InlineAdornment`]s. Mirrors the diagnostics half of /// `scoped_decorations`: same path → URI → store (`for_uri`) lookup. /// Step 0 established inlay-hint columns are already pmacs byte /// offsets by the time they reach the store (the absorb path's /// `inbound_converted` rewrites the `Position`-shaped /// `InlayHint.position`), so `line_col_to_byte` — which treats the /// column as a byte offset — is exact here, no per-server encoding /// needed (unlike semantic-token styling). Takes no `self`: the /// session id is irrelevant (inlay hints are per-buffer, not /// per-window like selection), mirroring `scoped_style_spans`. /// /// Every hint is `AtOffset` (inlay hints are inline by definition) /// carrying `Text` with the (padding-applied) label and the default /// style — the instance has no inlay-specific theme face yet, and a /// fabricated one would be dishonest. Stale store entries are /// suppressed the same way stale diagnostics / semantic tokens are: /// a zero-width hint anchored to pre-edit text is still a byte range /// bug, even though it has no source-byte width of its own. fn scoped_inline_adornments(state: &EditorState, vp: &DeclaredViewport) -> Vec { let core = state.core.borrow(); let Some(uri) = buffer_file_uri(&core, vp.buffer_id) else { return Vec::new(); }; let hints = { let store = state.lsp_manager.borrow().inlay_hint_store(); let guard = store.lock().expect("inlay-hint store mutex poisoned"); if guard.is_stale(&uri) { return Vec::new(); } match guard.for_uri(&uri) { Some(resp) => resp.hints.clone(), None => return Vec::new(), } }; if hints.is_empty() { return Vec::new(); } let registry = core.registry.clone(); let reg = registry.borrow(); let Ok(buf) = reg.get(vp.buffer_id) else { return Vec::new(); }; let source = buffer_source_bytes(buf); let source_len = source.len() as u64; let line_starts = line_start_offsets(&source); let vis_start = vp.visible.start.min(source_len); let vis_end = vp.visible.end.min(source_len); let mut out = Vec::new(); for h in &hints { let at = line_col_to_byte(&line_starts, source_len, h.line, h.col); // An inlay hint occupies no bytes; include it when its anchor // lies within the declared viewport (half-open). if at < vis_start || at >= vis_end { continue; } let mut text = String::new(); if h.padding_left { text.push(' '); } text.push_str(&h.label); if h.padding_right { text.push(' '); } out.push(InlineAdornment { at, placement: AdornmentPlacement::AtOffset, content: AdornmentContent::Text { text, style: Style::default(), }, }); } out } /// Intersect `[lo, hi)` with the declared viewport (itself clamped to /// the source length is the caller's concern for styling; for /// decorations we clamp against the viewport only). `None` when the /// intersection is empty or degenerate. fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option { let start = lo.max(vp.visible.start); let end = hi.min(vp.visible.end); if end <= start { return None; } Some(ByteRange { start, end }) } /// T M11.4 — the dirty byte intervals between two ordered item sets. /// /// Items are byte-anchored (`range_of` extracts the range). The /// symmetric difference (items in exactly one set, by `==`) bounds /// every byte whose covering set changed; its ranges are coalesced /// into maximal disjoint intervals — the segments the frontend will /// clear and repaint. Empty result ⇒ unchanged ⇒ the caller emits /// nothing. /// /// O(n·m) membership scans: a screenful is a few hundred items, far /// cheaper than re-shipping the whole viewport every frame, and only /// runs when the fast `prev == curr` slice check (caller side, via /// the order-stable producers) would have failed anyway. fn changed_intervals( prev: &[T], curr: &[T], range_of: impl Fn(&T) -> ByteRange, ) -> Vec { let mut changed: Vec = Vec::new(); for p in prev { if !curr.contains(p) { changed.push(range_of(p)); } } for c in curr { if !prev.contains(c) { changed.push(range_of(c)); } } coalesce_ranges(&mut changed) } /// Sort and merge overlapping or touching ranges into maximal /// disjoint intervals. Zero-width ranges are dropped (nothing to /// repaint). Consumes `ranges` (sorts in place). fn coalesce_ranges(ranges: &mut Vec) -> Vec { ranges.retain(|r| r.end > r.start); ranges.sort_by_key(|r| (r.start, r.end)); let mut out: Vec = Vec::new(); for r in ranges.iter().copied() { match out.last_mut() { // Touching (`>=`) merges too: adjacent dirty ranges become // one segment rather than two abutting clears. Some(last) if r.start <= last.end => last.end = last.end.max(r.end), _ => out.push(r), } } out } /// Every span intersecting `iv`, clipped to it, order preserved. fn clip_style_spans(iv: ByteRange, spans: &[StyleSpan]) -> Vec { spans .iter() .filter_map(|s| { let start = s.range.start.max(iv.start); let end = s.range.end.min(iv.end); (end > start).then_some(StyleSpan { range: ByteRange { start, end }, style: s.style, }) }) .collect() } /// Every decoration intersecting `iv`, clipped to it, order preserved. fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec { decos .iter() .filter_map(|d| { let start = d.range.start.max(iv.start); let end = d.range.end.min(iv.end); (end > start).then_some(Decoration { range: ByteRange { start, end }, kind: d.kind, }) }) .collect() } /// True for the four diagnostic-underline decoration kinds — the /// family whose emission is gated on diag-store staleness by the /// hold-while-stale logic in `render_frame`. fn is_diagnostic_kind(kind: DecorationKind) -> bool { matches!( kind, DecorationKind::DiagnosticError | DecorationKind::DiagnosticWarning | DecorationKind::DiagnosticInfo | DecorationKind::DiagnosticHint ) } /// True when `buffer_id`'s entry in the diagnostics store is stale /// (the document changed since the last `publishDiagnostics` /// absorption). Buffers with no file URI are never stale. fn diagnostics_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { let core = state.core.borrow(); let Some(uri) = buffer_file_uri(&core, buffer_id) else { return false; }; let store = state.lsp_manager.borrow().diag_store(); let guard = store.lock().expect("diag store mutex poisoned"); guard.is_stale(&uri) } /// The diag store's per-URI change epoch for `buffer_id`'s file, `0` /// for buffers with no file URI or no diagnostics history. Keys the /// `FileStyleSummary` cache (see [`SemanticRenderState::last_summary`]). fn diagnostics_epoch(state: &EditorState, buffer_id: BufferId) -> u64 { let core = state.core.borrow(); let Some(uri) = buffer_file_uri(&core, buffer_id) else { return 0; }; let store = state.lsp_manager.borrow().diag_store(); let guard = store.lock().expect("diag store mutex poisoned"); guard.epoch_for(&uri) } /// Style-family staleness for the LSP-token authority. True only for /// a buffer with **no** tree-sitter view (policy A routes those /// through `lsp_scoped_style_spans`) whose semantic-token store entry /// is stale. Grammar-backed buffers always return `false` — their /// styling freshness is `grammar_style_parse_not_ready`'s job. fn lsp_style_tokens_stale(state: &EditorState, buffer_id: BufferId) -> bool { if state.syntax_registry.view(buffer_id).is_some() { return false; } let core = state.core.borrow(); let Some(uri) = buffer_file_uri(&core, buffer_id) else { return false; }; let store = state.lsp_manager.borrow().semantic_token_store(); let guard = store.lock().expect("semantic token store mutex poisoned"); guard.is_stale(&uri) } /// Inlay-hint twin of [`diagnostics_store_stale`]. fn inlay_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { let core = state.core.borrow(); let Some(uri) = buffer_file_uri(&core, buffer_id) else { return false; }; let store = state.lsp_manager.borrow().inlay_hint_store(); let guard = store.lock().expect("inlay-hint store mutex poisoned"); guard.is_stale(&uri) } /// Map an LSP diagnostic severity onto the wire decoration kind. fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind { use crate::diag::DiagnosticSeverity as S; match sev { S::Error => DecorationKind::DiagnosticError, S::Warning => DecorationKind::DiagnosticWarning, S::Information => DecorationKind::DiagnosticInfo, S::Hint => DecorationKind::DiagnosticHint, } } /// Resolve `buffer_id` → `file://…` URI, using the same encoding the /// Lua side uses in `file_uri_for` so the result is byte-identical to /// the diag-store / inlay-store / semantic-token-store keys. /// /// **Bug-fix anchor (post-session-5 finding):** the producer used to /// derive this URI from `core.active_buffer_path()` — the *editor's* /// active buffer, not the buffer the frame is projecting. In multi- /// frontend setups (TUI + `pmacs-gpu`) those diverge: each frontend /// has its own active window, and when the daemon renders frontend B's /// frame it temporarily flips `active_frontend` to B but B's active /// buffer may be a scratch buffer with no file path. The diag / /// inlay / semantic-token lookups would then run against the wrong /// URI (or `None`) and return empty, so frontend B got no /// `Decorations` / `InlineAdornments` / LSP-driven `StyleSpans`. /// Routing the URI through `vp.buffer_id` fixes the multi-frontend /// case without disturbing the single-frontend one /// (`active_buffer_id == vp.buffer_id` there, so the resolved path is /// the same). fn buffer_file_uri(core: &crate::editor_core::EditorCore, buffer_id: BufferId) -> Option { let reg = core.registry.borrow(); let buf = reg.get(buffer_id).ok()?; let path = buf.file_path()?; Some(crate::lsp::path_to_file_uri(path)) } /// Snapshot a buffer's bytes (refcount-cheap rope slice, mirroring /// `diag.rs`'s render-time snapshot). fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec { let len = buf.len(); let mut bytes = vec![0u8; len as usize]; if !bytes.is_empty() { buf.snapshot_rope().slice(0, len, &mut bytes); } bytes } /// Byte offset of the start of each line (index 0 = byte 0; one entry /// per line, where a line is a maximal run ended by `\n`). fn line_start_offsets(source: &[u8]) -> Vec { let mut starts = vec![0u64]; for (i, b) in source.iter().enumerate() { if *b == b'\n' { starts.push(i as u64 + 1); } } starts } /// Translate an LSP `(line, col)` to a byte offset. pmacs v0.1 treats /// the LSP column as a byte offset within the line (see /// `crate::diag::Diagnostic`'s field docs); we clamp to the line's /// end and the source length so a stale diagnostic from before an /// edit can never index out of range. fn line_col_to_byte(line_starts: &[u64], source_len: u64, line: u32, col: u32) -> u64 { let li = line as usize; let Some(&line_start) = line_starts.get(li) else { return source_len; }; let line_end = line_starts .get(li + 1) .map_or(source_len, |&next| next.saturating_sub(1)); (line_start + u64::from(col)).min(line_end).min(source_len) } /// Cheap recompute-gate key for [`scoped_style_spans`] on a grammar- /// backed buffer. Returns `None` for buffers with no tree-sitter view /// (the LSP-token path), which has no comparably cheap revision handle /// and therefore is never gated. `bundle.source_revision` is read via /// the same `current()` accessor `scoped_style_spans` uses, so the key /// flips exactly when the spans it would produce can change. fn grammar_style_key( state: &EditorState, vp: &DeclaredViewport, generation: u64, ) -> Option { let handle = state.syntax_registry.view(vp.buffer_id)?; Some(StyleGate { bundle: handle.current(), generation, visible: vp.visible, }) } fn grammar_style_parse_not_ready(state: &EditorState, buffer_id: BufferId) -> bool { let Some(handle) = state.syntax_registry.view(buffer_id) else { return false; }; handle.current().is_none() || handle.pending_edit_count() > 0 || state.syntax_registry.has_pending_parse_job_for(buffer_id) } /// Compute the styled byte runs intersecting the declared viewport, /// mapped through the active theme. Spans are clipped to the viewport /// and to the parsed source length; runs that resolve to the default /// style are dropped (wire economy, and consistent with the grid /// path, which skips default-style merges). fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec { // Policy A — per-language styling authority. A grammar-backed // language (the registry hands out a view only for those) is // styled *solely* by tree-sitter; a language with no bundled // grammar (C/C++, …) is styled *solely* by LSP semantic tokens. // Never both: this is why the no-view branch hands off to the LSP // producer while a grammar-backed buffer whose parse isn't ready // yet returns empty rather than briefly borrowing LSP styling // (which would flicker two authorities on one buffer). let Some(handle) = state.syntax_registry.view(vp.buffer_id) else { return lsp_scoped_style_spans(state, vp); }; let Some(bundle) = handle.current() else { return Vec::new(); }; let Some(query) = state .syntax_registry .highlights_query(&bundle.language_name) else { return Vec::new(); }; let theme = state .syntax_registry .theme() .lock() .expect("theme mutex poisoned") .clone(); let source_len = bundle.source.len() as u64; let vis_start = vp.visible.start.min(source_len); let vis_end = vp.visible.end.min(source_len); if vis_end <= vis_start { return Vec::new(); } let capture_names = query.capture_names(); // Scope the tree-sitter capture walk to the visible byte range so // re-styling on each edit is O(visible), not O(file) — the typing // bottleneck on large files (framing Q#S6). Captures whose nodes // intersect the range are returned, then clipped exactly below. let highlights = crate::syntax::compute_highlight_spans_in_range( &query, &bundle, Some(vis_start as usize..vis_end as usize), ); let mut out = Vec::new(); for hs in highlights { let s = u64::from(hs.start_byte).max(vis_start); let e = u64::from(hs.end_byte).min(vis_end); if e <= s { continue; // No overlap with the viewport. } let Some(name) = capture_names.get(hs.capture_index as usize) else { continue; }; let style = theme.lookup(name); if style == Style::default() { continue; // Nothing to render — skip the wire byte. } out.push(StyleSpan { range: ByteRange { start: s, end: e }, style, }); } out } /// Policy-A fallback for languages with no bundled tree-sitter /// grammar (C/C++, …): project the LSP semantic-token store into the /// same `StyleSpan` shape the tree-sitter path emits, so the existing /// M11.4 diff pipeline (`render_frame`) consumes it unchanged and the /// frontend never learns which producer fed it. The instance stays /// the single styling authority. /// /// `SemanticToken` `start`/`length` are LSP encoding units (UTF-16 /// for clangd's default) and — unlike inlay hints — are *not* /// byte-rewritten upstream, so this converts them per line via the /// owning server's negotiated encoding /// ([`crate::lsp::LspManager::semantic_style_context`]). Tokens are /// single-line by the LSP grammar, so per-line conversion is exact. fn lsp_scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec { let core = state.core.borrow(); let Some(uri) = buffer_file_uri(&core, vp.buffer_id) else { return Vec::new(); }; // Styling context (encoding + legend) and the token set resolve // the *same* server for `uri` (both via `for_uri`'s lowest-id // rule), so they describe one coherent source. let mgr = state.lsp_manager.borrow(); let Some(ctx) = mgr.semantic_style_context(&uri) else { return Vec::new(); }; let tokens = { let store = mgr.semantic_token_store(); let guard = store.lock().expect("semantic-token store mutex poisoned"); if guard.is_stale(&uri) { return Vec::new(); } match guard.for_uri(&uri) { Some((_, resp)) => resp.tokens.clone(), None => return Vec::new(), } }; drop(mgr); let registry = core.registry.clone(); let reg = registry.borrow(); let Ok(buf) = reg.get(vp.buffer_id) else { return Vec::new(); }; let source = buffer_source_bytes(buf); let source_len = source.len() as u64; let vis_start = vp.visible.start.min(source_len); let vis_end = vp.visible.end.min(source_len); if vis_end <= vis_start { return Vec::new(); } let line_starts = line_start_offsets(&source); let theme = state .syntax_registry .theme() .lock() .expect("theme mutex poisoned") .clone(); let mut out = Vec::new(); for t in &tokens { let li = t.line as usize; let Some(&ls) = line_starts.get(li) else { continue; // Token line past EOF (stale response) — skip. }; let le = line_starts .get(li + 1) .map_or(source_len, |&n| n.saturating_sub(1)); let Ok(line_text) = std::str::from_utf8(&source[ls as usize..le as usize]) else { continue; // Non-UTF-8 line — cannot do encoded conversion. }; let start_b = ls + crate::lsp::char_to_byte(line_text, t.start, ctx.encoding) as u64; let end_char = t.start.saturating_add(t.length); let end_b = ls + crate::lsp::char_to_byte(line_text, end_char, ctx.encoding) as u64; let s = start_b.max(vis_start); let e = end_b.min(vis_end); if e <= s { continue; // Empty, or no overlap with the viewport. } let Some(name) = ctx .legend .as_ref() .and_then(|lg| lg.type_name(t.token_type)) else { continue; // No legend / unknown type ⇒ cannot name a style. }; let style = theme.lookup(name); if style == Style::default() { continue; // Nothing to render — skip the wire byte (parity // with the tree-sitter path's default-style drop). } out.push(StyleSpan { range: ByteRange { start: s, end: e }, style, }); } out } /// Compute the per-line dominant style summary for the whole buffer: /// one [`Style`] per source line, in line order. The "dominant" style /// for a line is the one covering the most bytes among the styled /// runs (`scoped_style_spans` for the full buffer); a line with no /// styled runs takes [`Style::default`]. Reuses [`scoped_style_spans`] /// so the policy-A authority choice (tree-sitter for grammar-backed /// languages, LSP semantic tokens otherwise) is inherited automatically. /// /// `O(spans × lines)` in the worst case; the caller short-circuits on /// unchanged CRDT generation so this only runs on first sight of a /// buffer or after an edit, not per frame. fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec