pmacs/src/semantic_render.rs

2076 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<T> {
visible: ByteRange,
items: Vec<T>,
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<DeclaredViewport>,
/// 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<BufferId, LastFrame<StyleSpan>>,
/// 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<BufferId, LastFrame<Decoration>>,
/// `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<BufferId, LastFrame<InlineAdornment>>,
/// `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.
last_summary: HashMap<BufferId, u64>,
}
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(),
}
}
/// 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<InstanceMessage> {
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) ---
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 — see
// `LastFrame`'s doc comment).
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
if full {
// The first frame for this buffer/viewport. One segment
// covering the declared viewport carries the whole scoped
// set (possibly empty → frontend clears the viewport).
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.
}
// --- Decorations (T M11.3 producer, T M11.4 diff) ---
let decorations = self.scoped_decorations(state, &vp);
let prev = self.last_decorations.get(&vp.buffer_id);
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
if full {
self.last_decorations.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: decorations.clone(),
generation,
},
);
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<InstanceMessage> {
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<InstanceMessage> {
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
return None;
}
let lines = scoped_file_summary(state, buffer_id);
self.last_summary.insert(buffer_id, generation);
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.
fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec<Decoration> {
let core = state.core.borrow();
let mut out = Vec::new();
// Selection — per-window (per-frontend) state, already byte
// offsets. Only this session's active window for the declared
// buffer contributes.
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 registry = core.registry.clone();
let reg = registry.borrow();
if let Ok(buf) = reg.get(vp.buffer_id) {
let source = buffer_source_bytes(buf);
let line_starts = line_start_offsets(&source);
for d in &diags {
let lo = line_col_to_byte(
&line_starts,
source.len() as u64,
d.start_line,
d.start_col,
);
let hi = line_col_to_byte(
&line_starts,
source.len() as u64,
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<InlineAdornment> {
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<ByteRange> {
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<T: PartialEq>(
prev: &[T],
curr: &[T],
range_of: impl Fn(&T) -> ByteRange,
) -> Vec<ByteRange> {
let mut changed: Vec<ByteRange> = 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<ByteRange>) -> Vec<ByteRange> {
ranges.retain(|r| r.end > r.start);
ranges.sort_by_key(|r| (r.start, r.end));
let mut out: Vec<ByteRange> = 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<StyleSpan> {
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<Decoration> {
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()
}
/// 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<String> {
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<u8> {
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<u64> {
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)
}
/// 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<StyleSpan> {
// 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();
let highlights = crate::syntax::compute_highlight_spans(&query, &bundle);
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<StyleSpan> {
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<Style> {
let source = {
let core = state.core.borrow();
let registry = core.registry.clone();
let reg = registry.borrow();
match reg.get(buffer_id) {
Ok(buf) => buffer_source_bytes(buf),
Err(_) => return Vec::new(),
}
};
let source_len = source.len() as u64;
let line_starts = line_start_offsets(&source);
let line_count = line_starts.len();
let mut out = vec![Style::default(); line_count];
// Reuse the existing producer with a whole-buffer "viewport". The
// clip is then a no-op and `scoped_style_spans` yields every
// styled run; policy A's authority pick (tree-sitter / LSP) is
// therefore identical to the per-frame styling.
let vp_all = DeclaredViewport {
buffer_id,
visible: ByteRange {
start: 0,
end: source_len,
},
frontend_generation: 0,
};
let spans = scoped_style_spans(state, &vp_all);
if spans.is_empty() {
return out;
}
// Per-line dominant style: tally bytes per style and pick the
// winner. `Style` doesn't implement `Hash`, so a small linear-scan
// tally is fine — the number of distinct styles per line is
// bounded by the theme's vocabulary (single digits in practice).
for (li, line_dominant) in out.iter_mut().enumerate() {
let line_start = line_starts[li];
// Same trailing-newline semantics as `line_col_to_byte`: the
// line's byte range excludes the `\n` (`next - 1`), and the
// trailing line (no next entry) runs to EOF.
let line_end = line_starts
.get(li + 1)
.map_or(source_len, |&next| next.saturating_sub(1));
let mut tally: Vec<(Style, u64)> = Vec::new();
for sp in &spans {
let lo = sp.range.start.max(line_start);
let hi = sp.range.end.min(line_end);
if hi > lo {
let bytes = hi - lo;
if let Some(entry) = tally.iter_mut().find(|(s, _)| *s == sp.style) {
entry.1 += bytes;
} else {
tally.push((sp.style, bytes));
}
}
}
if let Some(winner) = tally.into_iter().max_by_key(|(_, c)| *c) {
*line_dominant = winner.0;
}
}
out
}
/// The buffer's CRDT version projected to a monotonic scalar — the
/// `generation` anchor for the semantic frame. `0` when the buffer is
/// absent or not CRDT-backed (a `semantic_render` session always
/// negotiates `crdt_replica`, so in practice the buffer is CRDT-backed
/// before any semantic frame is produced; the fallback keeps this
/// total).
#[cfg(feature = "crdt")]
fn buffer_generation(state: &EditorState, buffer_id: BufferId) -> u64 {
let core = state.core.borrow();
let registry = core.registry.clone();
let reg = registry.borrow();
reg.get(buffer_id)
.ok()
.and_then(crate::buffer::Buffer::crdt_state)
.map_or(0, crate::crdt::CrdtState::version_scalar)
}
/// Non-CRDT builds cannot host a semantic session (the negotiation
/// dependency rule requires `crdt_replica`, gated on the `crdt`
/// feature), so this is never reached with a live viewport; it exists
/// only to keep `render_frame` total across feature flavors.
#[cfg(not(feature = "crdt"))]
#[allow(clippy::missing_const_for_fn)]
fn buffer_generation(_state: &EditorState, _buffer_id: BufferId) -> u64 {
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::CellSize;
use crate::editor::EditorState;
use crate::instance_render::RenderState;
use crate::protocol::FrontendId;
fn empty_state() -> EditorState {
EditorState::new()
}
fn local() -> SemanticRenderState {
// FrontendId::LOCAL always has a registered FrontendView
// (EditorCore invariant), so `active_window_for(LOCAL)` — the
// selection projection's lookup — resolves in a fresh editor.
SemanticRenderState::new(FrontendId::LOCAL)
}
fn active_buffer(state: &EditorState) -> BufferId {
state.core.borrow().active_window().buffer_id
}
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, or
/// `FileStyleSummary` — never `CellDelta`, grid `Cursor`, or the
/// still-unwired `BlockAdornments` / `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
assert!(
matches!(
m,
InstanceMessage::StyleSpans { .. }
| InstanceMessage::Decorations { .. }
| InstanceMessage::InlineAdornments { .. }
| InstanceMessage::FileStyleSummary { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
}
}
/// Find the `Decorations` message and flatten its segments into
/// `(full, all decorations across segments)`.
fn decorations_of(msgs: &[InstanceMessage]) -> Option<(bool, Vec<Decoration>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((
*full,
segments
.iter()
.flat_map(|s| s.decorations.clone())
.collect(),
)),
_ => None,
})
}
/// Find the `StyleSpans` message: `(full, segment ranges)`.
fn style_segments(msgs: &[InstanceMessage]) -> Option<(bool, Vec<ByteRange>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::StyleSpans { full, segments, .. } => {
Some((*full, segments.iter().map(|s| s.range).collect()))
}
_ => None,
})
}
fn set_selection(state: &EditorState, anchor: u64, cursor: u64) {
let mut core = state.core.borrow_mut();
let win = core
.active_window_mut_for(FrontendId::LOCAL)
.expect("LOCAL always has a window");
win.selection = Some(crate::window::Selection { anchor });
win.cursor = cursor;
}
fn seed_diagnostic(state: &EditorState, buffer_id: BufferId) {
let mut core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(buffer_id)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"abc\nde",
})
.expect("seed buffer text");
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/m114.rs")));
drop(core);
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
let store = state.lsp_manager.borrow().diag_store();
store.lock().expect("diag store").set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 2,
severity: crate::diag::DiagnosticSeverity::Warning,
message: "x".into(),
source: None,
code: None,
}],
);
}
#[test]
fn emits_nothing_before_viewport_declared() {
let mut s = local();
assert!(
s.render_frame(&empty_state()).is_empty(),
"nothing may be emitted before the frontend declares a viewport"
);
}
#[test]
fn first_post_viewport_frame_is_full_for_both_then_suppresses() {
let state = empty_state();
let mut s = local();
let buffer_id = active_buffer(&state);
s.set_viewport(
buffer_id,
ByteRange {
start: 0,
end: 4096,
},
0,
);
// Empty scratch: no spans, no selection, no diagnostics — but
// the first frame is a `full` resync for both diffable families
// (the frontend clears its viewport), carrying empty segments.
// FileStyleSummary also emits on the first frame for this buffer
// (post-M11 minimap producer, generation-keyed).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
3,
"first frame ships StyleSpans + Decorations + FileStyleSummary"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
let (deco_full, decos) = decorations_of(&first).expect("Decorations present");
assert!(style_full, "first styling frame must be full");
assert!(deco_full, "first decorations frame must be full");
assert!(decos.is_empty(), "empty scratch has no decorations");
// Nothing changed → both families suppressed.
assert!(
s.render_frame(&state).is_empty(),
"an unchanged frame must be fully suppressed"
);
}
#[test]
fn selection_projects_as_a_decoration_clipped_to_viewport() {
let state = empty_state();
let buffer_id = active_buffer(&state);
// region (2,5) on LOCAL's window; region() compares offsets
// only, so the empty scratch buffer is fine here.
set_selection(&state, 2, 5);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 3, end: 64 }, 0);
let msgs = s.render_frame(&state);
assert_semantic_only(&msgs);
let (full, decos) = decorations_of(&msgs).expect("a Decorations message");
assert!(full, "first frame is a full resync");
assert_eq!(decos.len(), 1, "exactly the selection decoration");
assert_eq!(decos[0].kind, DecorationKind::Selection);
// region (2,5) clipped to viewport [3,64) → [3,5).
assert_eq!(decos[0].range, ByteRange { start: 3, end: 5 });
}
#[test]
fn diagnostics_project_with_line_col_to_byte_and_severity() {
// "abc\nde": line 0 at byte 0, line 1 at byte 4.
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert_eq!(decos.len(), 1);
assert_eq!(decos[0].kind, DecorationKind::DiagnosticWarning);
// line 1 starts at byte 4; cols [0,2) → bytes [4,6).
assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 });
}
/// T M11.8 regression: when the diag store's entry for the URI
/// is marked stale (an edit has been issued since the last
/// `publishDiagnostics`), the producer must suppress diagnostic
/// emission so the frontend doesn't paint colors at pre-edit
/// byte positions over post-edit text. Closes the bet-#1
/// surface that session-5 validation exposed.
#[test]
fn diagnostics_suppressed_while_diag_store_stale() {
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id);
// Mark the diag store stale for the seeded URI. The producer
// should now emit zero diagnostic decorations.
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
state
.lsp_manager
.borrow()
.diag_store()
.lock()
.expect("diag store")
.mark_stale(uri.clone());
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert!(
decos.iter().all(|d| !matches!(
d.kind,
DecorationKind::DiagnosticError
| DecorationKind::DiagnosticWarning
| DecorationKind::DiagnosticInfo
| DecorationKind::DiagnosticHint
)),
"stale diag store ⇒ no diagnostic decorations emitted; got {decos:?}"
);
// Once a fresh `set` clears the stale flag, the decoration
// re-appears on the next render frame.
state
.lsp_manager
.borrow()
.diag_store()
.lock()
.expect("diag store")
.set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 2,
severity: crate::diag::DiagnosticSeverity::Warning,
message: "x".into(),
source: None,
code: None,
}],
);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert!(
decos
.iter()
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
"fresh set ⇒ stale flag cleared ⇒ decoration re-emitted; got {decos:?}"
);
}
/// Regression: in a multi-frontend setup the editor's *active*
/// buffer (set by `core.active_buffer_id()`, derived from the
/// active frontend's view) can differ from the buffer a given
/// frontend's `SemanticRenderState` is projecting. The producer
/// used to derive the diagnostic URI from `active_buffer_path()`,
/// which returned the wrong path (or `None`) in that case and
/// emitted empty decorations — the session-5 manual-validation
/// finding. Fix routes the URI lookup through `vp.buffer_id` via
/// `buffer_file_uri`.
#[test]
fn decorations_use_vp_buffer_not_active_buffer() {
let state = empty_state();
// The default LOCAL frontend's active buffer is a scratch
// with no file path. We seed text + a file path on a buffer
// that is NOT the active one, then project that buffer via
// a separate `SemanticRenderState`. Pre-fix: the producer
// looks up `active_buffer_path()` (the scratch, no path) and
// gets `None`, emitting empty decorations. Post-fix: it
// resolves the URI from `vp.buffer_id`, finds the diagnostic.
let scratch_id = active_buffer(&state);
// Create a second buffer with a real file path + a diagnostic.
let file_id = {
let core = state.core.borrow();
core.registry
.borrow_mut()
.create_from_bytes("secondary".to_owned(), b"abc\nde")
};
{
let mut core = state.core.borrow_mut();
core.set_buffer_path(file_id, Some(std::path::PathBuf::from("/tmp/multi-fid.rs")));
}
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/multi-fid.rs"));
state
.lsp_manager
.borrow()
.diag_store()
.lock()
.expect("diag store")
.set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 2,
severity: crate::diag::DiagnosticSeverity::Error,
message: "boom".into(),
source: None,
code: None,
}],
);
// Sanity: active buffer is still the scratch, not `file_id`.
assert_eq!(scratch_id, active_buffer(&state));
assert_ne!(scratch_id, file_id);
// Project the file buffer through the semantic renderer.
let mut s = local();
s.set_viewport(file_id, ByteRange { start: 0, end: 64 }, 0);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert_eq!(
decos.len(),
1,
"decoration must surface for the projected buffer even when it's not the editor's active buffer"
);
assert_eq!(decos[0].kind, DecorationKind::DiagnosticError);
assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 });
}
#[test]
fn styles_and_decorations_suppress_independently() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state); // first frame: both full
assert!(s.render_frame(&state).is_empty(), "steady state silent");
// A selection appears → only Decorations re-emits, and as an
// incremental (full = false) frame since the viewport region
// did not move.
set_selection(&state, 1, 4);
let msgs = s.render_frame(&state);
assert_eq!(msgs.len(), 1, "only the changed family re-emits");
let (full, decos) = decorations_of(&msgs).expect("Decorations re-emitted");
assert!(!full, "viewport unchanged → incremental, not full");
assert_eq!(decos.len(), 1);
assert_eq!(decos[0].kind, DecorationKind::Selection);
}
#[test]
fn full_resync_on_viewport_region_change() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state); // full
assert!(s.render_frame(&state).is_empty(), "unchanged → silent");
// Declaring a different on-screen range forces a full resync:
// prior styling/decorations are positioned for the old window.
s.set_viewport(
buffer_id,
ByteRange {
start: 200,
end: 264,
},
0,
);
let msgs = s.render_frame(&state);
let (style_full, _) = style_segments(&msgs).expect("StyleSpans");
let (deco_full, _) = decorations_of(&msgs).expect("Decorations");
assert!(
style_full && deco_full,
"viewport jump must be a full resync"
);
}
/// T M11.7 regression: a CRDT generation transition forces a
/// `full=true` resync on the next frame for both `StyleSpans`
/// and `Decorations`, even when the declared viewport is
/// unchanged. Without this, an edit at byte position N would
/// leave the frontend with stale spans/decorations indexed at
/// pre-edit byte positions while only a small dirty-range
/// increment ships — which violates the incremental contract
/// (the increment expects the frontend to retain non-dirty
/// items). The bet-#1 surface for `pmacs-gpu`.
#[cfg(feature = "crdt")]
#[test]
fn full_resync_on_generation_transition() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state); // initial full
assert!(
s.render_frame(&state).is_empty(),
"unchanged frame → silent"
);
// Upgrade the buffer to CRDT-backed so `buffer_generation`
// tracks edits — without this, version_scalar stays 0 for a
// v0.1-style buffer and the producer can't detect transitions.
// Then bump the buffer's generation by editing it.
{
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
let buf = reg.get_mut(buffer_id).expect("active buffer");
buf.upgrade_to_crdt(1).expect("upgrade to crdt");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"x",
})
.expect("buffer edit");
}
// Generation transitioned → next frame must be full for both
// diff-shaped families.
let msgs = s.render_frame(&state);
let (style_full, _) = style_segments(&msgs).expect("StyleSpans re-emitted");
let (deco_full, _) = decorations_of(&msgs).expect("Decorations re-emitted");
assert!(
style_full,
"post-edit StyleSpans must be full=true (positions shifted)"
);
assert!(
deco_full,
"post-edit Decorations must be full=true (positions shifted)"
);
}
#[test]
fn incremental_decoration_change_ships_only_dirty_intervals() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 256 }, 0);
set_selection(&state, 10, 12);
let _ = s.render_frame(&state); // full: selection [10,12)
assert!(s.render_frame(&state).is_empty());
// Move the selection far away. The symmetric difference is the
// old range [10,12) (removed) and the new [40,42) (added);
// they are disjoint and non-adjacent → two segments.
set_selection(&state, 40, 42);
let msgs = s.render_frame(&state);
let deco_msg = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)),
_ => None,
})
.expect("Decorations");
assert!(!deco_msg.0, "incremental");
let ranges: Vec<ByteRange> = deco_msg.1.iter().map(|s| s.range).collect();
assert_eq!(
ranges,
vec![
ByteRange { start: 10, end: 12 },
ByteRange { start: 40, end: 42 }
],
"two disjoint dirty intervals: old (cleared) + new"
);
// The [10,12) segment carries no decorations (selection moved
// away → frontend clears it); [40,42) carries the new one.
let s1 = &deco_msg.1[0];
assert!(s1.decorations.is_empty(), "old selection range cleared");
let s2 = &deco_msg.1[1];
assert_eq!(s2.decorations.len(), 1);
assert_eq!(s2.decorations[0].kind, DecorationKind::Selection);
}
#[test]
fn unchanged_decoration_overlapping_a_dirty_interval_is_reconstructed() {
// A diagnostic at [4,6) never changes; the selection moves to
// overlap it. The dirty segment must still carry the (clipped)
// diagnostic so the frontend, replacing styling within the
// range, faithfully reconstructs the unchanged decoration.
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id); // Warning [4,6)
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
set_selection(&state, 20, 22);
let _ = s.render_frame(&state); // full: Sel[20,22) + Warn[4,6)
assert!(s.render_frame(&state).is_empty());
// Selection moves to [5,7), overlapping the diagnostic.
set_selection(&state, 5, 7);
let msgs = s.render_frame(&state);
let (_full, segs) = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)),
_ => None,
})
.expect("Decorations");
// The segment covering [5,7) must include the unchanged,
// overlapping diagnostic (clipped into the dirty range),
// not just the moved selection.
let overlapping = segs
.iter()
.find(|s| s.range.start <= 5 && s.range.end >= 6)
.expect("a segment covering the diagnostic's bytes");
assert!(
overlapping
.decorations
.iter()
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
"unchanged overlapping diagnostic must be reconstructed in the dirty segment"
);
}
#[test]
fn block_adornments_and_fold_state_still_never_emitted() {
// BlockAdornments / FoldState have no instance-side source
// yet, so the projection never produces them (not even empty).
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
for _ in 0..3 {
for m in s.render_frame(&state) {
assert!(
!matches!(
m,
InstanceMessage::BlockAdornments { .. } | InstanceMessage::FoldState { .. }
),
"a still-unwired block/fold family was emitted: {m:?}"
);
}
}
}
#[test]
fn inline_adornments_not_emitted_without_hints() {
// Step 3: InlineAdornments IS wired, but a buffer with no LSP
// inlay-hint store entry must still never emit an (empty)
// InlineAdornments frame — no empty-frame spam.
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
for _ in 0..3 {
assert!(
!s.render_frame(&state)
.iter()
.any(|m| matches!(m, InstanceMessage::InlineAdornments { .. })),
"no inlay hints ⇒ no InlineAdornments message"
);
}
}
#[test]
fn sibling_of_render_state_reads_same_editor_state() {
// The dispatcher selects the projection per session, not per
// buffer: a grid RenderState and a SemanticRenderState observe
// the same EditorState without interfering.
let state = empty_state();
let mut grid = RenderState::new(CellSize::new(24, 80));
let mut sem = local();
let buffer_id = active_buffer(&state);
sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0);
let grid_msgs = grid.render_frame(&state, &[]);
let sem_msgs = sem.render_frame(&state);
assert!(
matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }),
"grid projection still produces CellDelta"
);
assert_semantic_only(&sem_msgs);
assert!(
!sem_msgs
.iter()
.any(|m| matches!(m, InstanceMessage::CellDelta { .. })),
"semantic projection never produces CellDelta"
);
}
// --- Step 2: LSP-semantic-token styling authority (policy A) ---
fn tok(line: u32, start: u32, length: u32) -> crate::semantic_tokens::SemanticToken {
crate::semantic_tokens::SemanticToken {
line,
start,
length,
token_type: 0, // index 0 in the seeded legend → "kw"
token_modifiers: 0,
}
}
/// Overwrite the semantic-token store entry for the test client
/// `sid` on the `/tmp/x.cpp` URI.
fn set_tokens(
state: &EditorState,
sid: crate::lsp::LspServerId,
tokens: Vec<crate::semantic_tokens::SemanticToken>,
) {
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/x.cpp"));
let store = state.lsp_manager.borrow().semantic_token_store();
store.lock().expect("sem token store").set(
crate::semantic_tokens::SemanticTokenKey::new(sid.raw().to_string(), uri),
crate::semantic_tokens::SemanticTokensResponse {
tokens,
result_id: None,
raw: Vec::new(),
},
);
}
/// Seed a grammar-less (`.cpp`) buffer plus an Initialized test
/// LSP client advertising a one-entry legend (`["kw"]`, UTF-16),
/// a theme face for `kw`, and `tokens` in the store. Returns the
/// client id so a test can re-seed the same `(server, uri)`.
fn seed_lsp_style(
state: &EditorState,
buffer_id: BufferId,
text: &[u8],
tokens: Vec<crate::semantic_tokens::SemanticToken>,
) -> crate::lsp::LspServerId {
{
let mut core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(buffer_id)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: text,
})
.expect("seed buffer text");
// `.cpp` has no bundled tree-sitter grammar → the registry
// yields no view → policy A routes to the LSP producer.
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.cpp")));
}
state.syntax_registry.theme().lock().expect("theme").insert(
"kw",
crate::cell::Style {
bold: true,
..crate::cell::Style::default()
},
);
let sid = state
.lsp_manager
.borrow_mut()
.insert_initialized_test_client(
serde_json::json!({
"semanticTokensProvider": {
"legend": { "tokenTypes": ["kw"], "tokenModifiers": [] }
}
}),
crate::lsp::PositionEncoding::Utf16,
);
set_tokens(state, sid, tokens);
sid
}
#[test]
fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// "int" on line 0, bytes [0,3).
seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
let msgs = s.render_frame(&state);
assert_semantic_only(&msgs);
let spans = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::StyleSpans { full, segments, .. } => {
assert!(*full, "first frame is a full resync");
Some(
segments
.iter()
.flat_map(|seg| seg.spans.clone())
.collect::<Vec<_>>(),
)
}
_ => None,
})
.expect("StyleSpans emitted from the LSP authority");
assert_eq!(spans.len(), 1, "the one LSP token → one span");
assert_eq!(spans[0].range, ByteRange { start: 0, end: 3 });
assert!(
spans[0].style.bold,
"token_type resolved through legend → theme face"
);
}
#[test]
fn lsp_style_suppressed_when_unchanged() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
let _ = s.render_frame(&state); // full baseline
let again = s.render_frame(&state);
assert!(
!again
.iter()
.any(|m| matches!(m, InstanceMessage::StyleSpans { .. })),
"unchanged LSP styling reuses the M11.4 byte-identical suppression"
);
}
#[test]
fn lsp_style_incremental_on_token_change() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
let sid = seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
let _ = s.render_frame(&state); // full baseline
// Token now covers a different range ([4,5) = "x").
set_tokens(&state, sid, vec![tok(0, 4, 1)]);
let delta = s.render_frame(&state);
let (full, _) = style_segments(&delta).expect("StyleSpans re-emitted");
assert!(!full, "a changed token set ships an incremental frame");
}
#[test]
fn lsp_style_empty_when_no_tokens_for_grammarless_buffer() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
{
let mut core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(bid)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"int x;\n",
})
.expect("seed text");
core.set_buffer_path(bid, Some(std::path::PathBuf::from("/tmp/x.cpp")));
}
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
let msgs = s.render_frame(&state);
// Still a full resync (the frontend must be told "nothing
// here"), but with no spans — no LSP store, no panic, honest
// empty rather than a fabricated style.
let styled = msgs.iter().find_map(|m| match m {
InstanceMessage::StyleSpans { full, segments, .. } => Some((*full, segments.clone())),
_ => None,
});
let (full, segments) = styled.expect("a full StyleSpans resync");
assert!(full);
assert!(
segments.iter().all(|sg| sg.spans.is_empty()),
"grammar-less buffer with no LSP tokens ⇒ zero spans"
);
}
// --- Step 3: InlineAdornments from the LSP inlay-hint store ---
fn inlay_uri() -> String {
crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/h.rs"))
}
/// Seed the inlay-hint store. Keyed by `(server, uri)`; `for_uri`
/// picks the lowest server, so a fixed `"1"` is fine. No LSP
/// client needed — the inlay path never consults
/// `semantic_style_context` (cols are already byte offsets, Step 0).
fn set_inlay_store(state: &EditorState, uri: &str, hints: Vec<crate::inlay_hint::InlayHint>) {
let store = state.lsp_manager.borrow().inlay_hint_store();
store.lock().expect("inlay store").set(
crate::inlay_hint::InlayHintKey::new("1", uri),
crate::inlay_hint::InlayHintResponse { hints },
);
}
/// Seed buffer text + path and an inlay-hint store entry.
fn seed_inlay(
state: &EditorState,
buffer_id: BufferId,
hints: Vec<crate::inlay_hint::InlayHint>,
) {
{
let mut core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(buffer_id)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"let x = f();\n",
})
.expect("seed buffer text");
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/h.rs")));
}
set_inlay_store(state, &inlay_uri(), hints);
}
fn hint(line: u32, col: u32, label: &str) -> crate::inlay_hint::InlayHint {
crate::inlay_hint::InlayHint {
line,
col,
label: label.into(),
kind: None,
padding_left: false,
padding_right: true,
tooltip: None,
}
}
fn adornments_of(msgs: &[InstanceMessage]) -> Option<Vec<InlineAdornment>> {
msgs.iter().find_map(|m| match m {
InstanceMessage::InlineAdornments { items, .. } => Some(items.clone()),
_ => None,
})
}
#[test]
fn inlay_hints_project_as_inline_adornments_clipped_to_viewport() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// `: i32` after `x` (col 5, in-viewport) and a hint past the
// declared viewport that must be excluded.
seed_inlay(&state, bid, vec![hint(0, 5, ": i32"), hint(0, 11, "OUT")]);
s.set_viewport(bid, ByteRange { start: 0, end: 8 }, 0);
let msgs = s.render_frame(&state);
assert_semantic_only(&msgs);
let items = adornments_of(&msgs).expect("InlineAdornments emitted");
assert_eq!(items.len(), 1, "the out-of-viewport hint is clipped");
let a = &items[0];
assert_eq!(a.at, 5);
assert_eq!(a.placement, AdornmentPlacement::AtOffset);
match &a.content {
AdornmentContent::Text { text, style } => {
assert_eq!(text, ": i32 ", "padding_right adds a trailing space");
assert_eq!(*style, Style::default());
}
AdornmentContent::Resource { .. } => panic!("expected Text, got Resource"),
}
}
#[test]
fn inline_adornments_suppressed_then_resync_on_change() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
seed_inlay(&state, bid, vec![hint(0, 5, ": i32")]);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
assert!(
adornments_of(&s.render_frame(&state)).is_some(),
"first frame ships the adornments"
);
assert!(
adornments_of(&s.render_frame(&state)).is_none(),
"byte-identical next frame is suppressed"
);
// Hint set changes → whole-set re-send (no segment diffing on
// this wire variant).
seed_inlay(&state, bid, vec![hint(0, 5, ": String")]);
let again = adornments_of(&s.render_frame(&state)).expect("re-sent on change");
match &again[0].content {
AdornmentContent::Text { text, .. } => assert_eq!(text, ": String "),
AdornmentContent::Resource { .. } => panic!("expected Text, got Resource"),
}
}
#[test]
fn inline_adornments_emit_empty_clear_while_inlay_store_stale() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
seed_inlay(&state, bid, vec![hint(0, 5, ": i32")]);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
assert!(
adornments_of(&s.render_frame(&state)).is_some(),
"first frame ships the adornments"
);
let uri = inlay_uri();
let store = state.lsp_manager.borrow().inlay_hint_store();
store.lock().expect("inlay store").mark_stale(uri.clone());
let clear =
adornments_of(&s.render_frame(&state)).expect("stale transition clears adornments");
assert!(
clear.is_empty(),
"stale hints must clear the frontend's cached virtual text"
);
assert!(
adornments_of(&s.render_frame(&state)).is_none(),
"unchanged stale-empty state is suppressed after the clear"
);
set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]);
let refreshed = adornments_of(&s.render_frame(&state)).expect("fresh hints re-emit");
assert_eq!(refreshed.len(), 1);
assert_eq!(refreshed[0].at, 5);
}
#[cfg(feature = "crdt")]
#[test]
fn session8_temporal_probe_sustained_edits_clear_stale_inlays_until_refresh() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
let path = std::path::PathBuf::from("/tmp/session8-inlay.txt");
let uri = crate::lsp::path_to_file_uri(&path);
{
let mut core = state.core.borrow_mut();
let mut reg = core.registry.borrow_mut();
let buf = reg.get_mut(bid).expect("active buffer");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"let x = f();\n",
})
.expect("seed buffer text");
buf.upgrade_to_crdt(1).expect("upgrade to crdt");
drop(reg);
core.set_buffer_path(bid, Some(path));
}
set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]);
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
assert!(
adornments_of(&s.render_frame(&state)).is_some(),
"baseline emits the fresh inlay hint"
);
let mut clear_frames = 0;
let mut full_style_frames = 0;
let mut full_deco_frames = 0;
for _ in 0..1000 {
{
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
let buf = reg.get_mut(bid).expect("active buffer");
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"x",
})
.expect("typing edit");
}
let store = state.lsp_manager.borrow().inlay_hint_store();
store.lock().expect("inlay store").mark_stale(uri.clone());
let msgs = s.render_frame(&state);
if let Some((full, _)) = style_segments(&msgs)
&& full
{
full_style_frames += 1;
}
if let Some((full, _)) = decorations_of(&msgs)
&& full
{
full_deco_frames += 1;
}
if let Some(items) = adornments_of(&msgs) {
assert!(
items.is_empty(),
"stale inlay hints must not render during sustained typing"
);
clear_frames += 1;
}
}
assert_eq!(
clear_frames, 1,
"first stale frame clears cached hints; later stale frames stay silent"
);
assert_eq!(
full_style_frames, 1000,
"each CRDT generation transition forces a StyleSpans full resync"
);
assert_eq!(
full_deco_frames, 1000,
"each CRDT generation transition forces a Decorations full resync"
);
set_inlay_store(&state, &uri, vec![hint(0, 1005, ": i32")]);
let refreshed = adornments_of(&s.render_frame(&state)).expect("fresh hints re-emit");
assert_eq!(refreshed.len(), 1);
assert_eq!(refreshed[0].at, 1005);
}
// --- M1: FileStyleSummary (minimap producer, Open Q#2) ---
fn summary_of(msgs: &[InstanceMessage]) -> Option<(u64, Vec<Style>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::FileStyleSummary {
generation, lines, ..
} => Some((*generation, lines.clone())),
_ => None,
})
}
#[test]
fn file_style_summary_emitted_on_first_frame_and_suppressed_thereafter() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// Tiny buffer with no LSP / grammar setup → no styled runs →
// per-line dominant style stays `Style::default()`. The point
// of this test is the emit / suppress lifecycle, not content.
{
let core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(bid)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"a\nb\nc\n",
})
.expect("seed");
}
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let first = s.render_frame(&state);
assert_semantic_only(&first);
let (_gen, lines) = summary_of(&first).expect("first frame ships a summary");
// 4 lines: "a", "b", "c", "" (trailing empty after final \n).
assert_eq!(lines.len(), 4);
assert!(
lines.iter().all(|s| *s == Style::default()),
"no styled runs ⇒ every line takes the default style"
);
// Same generation → no re-emission.
let again = s.render_frame(&state);
assert!(
summary_of(&again).is_none(),
"unchanged generation suppresses the summary"
);
}
#[test]
fn file_style_summary_dominant_style_from_lsp_per_line() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// `.cpp` buffer (no tree-sitter grammar) so styling comes from
// the LSP semantic-token authority (policy A). Three lines;
// line 0 has a token spanning bytes 0..3, line 2 has one
// spanning bytes 8..11. Line 1 has no tokens.
//
// Buffer layout (newlines included):
// bytes 0..4 "abc\n" line 0 = [0,3) ← token [0,3)
// bytes 4..8 "def\n" line 1 = [4,7) ← no token
// bytes 8..12 "ghi\n" line 2 = [8,11) ← token [8,11)
let sid = seed_lsp_style(
&state,
bid,
b"abc\ndef\nghi\n",
vec![tok(0, 0, 3), tok(2, 0, 3)],
);
let _ = sid;
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let msgs = s.render_frame(&state);
let (_, lines) = summary_of(&msgs).expect("summary emitted");
assert_eq!(lines.len(), 4, "three lines + trailing empty");
let kw_style = crate::cell::Style {
bold: true,
..crate::cell::Style::default()
};
assert_eq!(lines[0], kw_style, "line 0 dominated by the LSP token");
assert_eq!(lines[1], Style::default(), "line 1 has no token → default");
assert_eq!(lines[2], kw_style, "line 2 dominated by the LSP token");
assert_eq!(lines[3], Style::default(), "trailing empty line → default");
}
}