producer: settle-gated styling + hold-while-stale for diagnostics/inlays/tokens

Typing-perf + render-churn fixes in the semantic producer:

- Grammar styling waits for parse settle (pending edits, in-flight
  parse job, or no installed bundle ⇒ hold the previous spans rather
  than querying stale syntax per typed byte); FileStyleSummary
  debounces on the same condition.
- CurrentLine is no longer emitted for semantic frontends — the GPU
  paints its own caret/current-line and the derivation forced a
  whole-buffer line table every frame.
- Hold-while-stale: while the diag / inlay-hint / semantic-token
  stores are stale (document edited since the last server response),
  emit NOTHING instead of a clearing frame. The frontend's
  last-received set — which it translates through its own local
  edits — is strictly better than an empty wipe (diagnostics blinked
  out per typing burst and back in per publish, a full frontend
  reshape each way; inlay wipes visibly shifted line layout; the
  LSP-token path blanked C++ colors). A selection change during the
  stale window still ships, without the diagnostic kinds.
- Diagnostics byte<->line table cached per buffer revision (was an
  O(buffer) rope copy + scan on every tick a diagnostic was visible).
- Empty->empty Decorations frames on generation bumps suppressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 10:48:16 -04:00
parent 8ed15d86a1
commit e7669c9d83
1 changed files with 475 additions and 167 deletions

View File

@ -124,6 +124,21 @@ pub struct SemanticRenderState {
/// 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<BufferId, StyleGate>,
/// 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<BufferId, DiagLineCache>,
}
/// 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<u64>,
source_len: u64,
}
/// Recompute gate for [`scoped_style_spans`] on a grammar-backed
@ -169,6 +184,7 @@ impl SemanticRenderState {
last_adornments: HashMap::new(),
last_summary: HashMap::new(),
last_style_gate: HashMap::new(),
diag_line_cache: HashMap::new(),
}
}
@ -220,14 +236,29 @@ impl SemanticRenderState {
// 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_gate = grammar_style_key(state, &vp, generation);
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_unchanged {
// Styling cannot have changed since the last computation;
// emit nothing and skip the query.
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) => {
@ -243,8 +274,36 @@ impl SemanticRenderState {
// --- 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);
// 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 = diagnostics_store_stale(state, vp.buffer_id)
&& prev.is_some_and(|p| {
decorations
.iter()
.eq(p.items.iter().filter(|d| !is_diagnostic_kind(d.kind)))
});
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
if full {
if held {
// No new information for the frontend this frame. (A
// selection change during the stale window falls through
// to the branches below and ships without diagnostics —
// rare, and better than pinning a dead selection.)
} 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 {
@ -253,15 +312,17 @@ impl SemanticRenderState {
generation,
},
);
out.push(InstanceMessage::Decorations {
buffer_id: vp.buffer_id,
generation,
full: true,
segments: vec![DecorationSegment {
range: vp.visible,
decorations,
}],
});
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);
@ -308,6 +369,17 @@ impl SemanticRenderState {
state: &EditorState,
vp: &DeclaredViewport,
) -> Option<InstanceMessage> {
// 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
@ -359,13 +431,13 @@ impl SemanticRenderState {
// 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 a grammar-backed buffer, debounce it to
// reparse-completion: skip while a reparse is in flight
// (`current_fresh` is `None`), so during continuous typing the
// whole-file pass runs at reparse rate, not keystroke rate.
if let Some(handle) = state.syntax_registry.view(buffer_id)
&& handle.pending_edit_count() > 0
{
// 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;
}
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
@ -456,56 +528,31 @@ impl SemanticRenderState {
}
}
fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec<Decoration> {
fn scoped_decorations(
&mut self,
state: &EditorState,
vp: &DeclaredViewport,
) -> Vec<Decoration> {
let core = state.core.borrow();
let registry = core.registry.clone();
let reg = registry.borrow();
let mut out = Vec::new();
// Byte<->line mapping is needed by both the CurrentLine
// derivation and the diagnostics projection, and
// `buffer_source_bytes` is an O(n) rope copy. This runs every
// tick in the daemon's hot loop, so materialize at most once
// per call and reuse — never twice (the pre-9.2 shape copied
// separately in each branch).
let mut line_info: Option<(Vec<u8>, Vec<u64>)> = None;
// Selection + CurrentLine — per-window (per-frontend) state.
// Only this session's active window for the declared buffer
// contributes either kind.
//
// Q#3 (per-line CurrentLine cadence, stance β) falls out of the
// existing M11.4 diff: `render_frame` compares the new
// decoration Vec against the last sent one and emits only on
// change. Horizontal cursor motion within a single line
// produces an identical `CurrentLine` range and an identical
// overall Vec, so `changed_intervals` returns empty and nothing
// ships. No `last_cursor_line` cache is needed at this layer.
// 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)
{
if let Some((lo, hi)) = win.region()
&& let Some(range) = clip_to_viewport(lo, hi, vp)
{
out.push(Decoration {
range,
kind: DecorationKind::Selection,
});
}
if let Ok(buf) = reg.get(vp.buffer_id) {
let (source, line_starts) = line_info.get_or_insert_with(|| {
let s = buffer_source_bytes(buf);
let ls = line_start_offsets(&s);
(s, ls)
});
let (lo, hi) = current_line_range(line_starts, source.len() as u64, win.cursor);
if let Some(range) = clip_to_viewport(lo, hi, vp) {
out.push(Decoration {
range,
kind: DecorationKind::CurrentLine,
});
}
}
out.push(Decoration {
range,
kind: DecorationKind::Selection,
});
}
// Diagnostics — keyed in the shared store by the file URI the
@ -530,12 +577,30 @@ impl SemanticRenderState {
&& !diags.is_empty()
&& let Ok(buf) = reg.get(vp.buffer_id)
{
let (source, line_starts) = line_info.get_or_insert_with(|| {
let s = buffer_source_bytes(buf);
let ls = line_start_offsets(&s);
(s, ls)
});
let source_len = source.len() as u64;
// 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);
@ -723,6 +788,61 @@ fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec<Decoration> {
.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)
}
/// 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;
@ -770,31 +890,6 @@ fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec<u8> {
bytes
}
/// Byte range `(start, end)` of the line containing `cursor`, where
/// `start` is the position right after the previous `\n` (or 0 for the
/// first line) and `end` is the position of the next `\n` (or
/// `source_len` for the last line). Used by `scoped_decorations` to
/// emit `DecorationKind::CurrentLine`; clamps so a cursor at or past
/// `source_len` returns the last line's range rather than indexing
/// out.
fn current_line_range(line_starts: &[u64], source_len: u64, cursor: u64) -> (u64, u64) {
// `partition_point` returns the count of leading elements satisfying
// the predicate, i.e. the index of the first `line_start > cursor`.
// Subtracting 1 yields the index of the largest `line_start <=
// cursor`. `line_starts` always starts with 0, so the saturating
// sub is defensive against an empty `line_starts`.
let idx = line_starts
.partition_point(|&start| start <= cursor)
.saturating_sub(1);
let lo = line_starts.get(idx).copied().unwrap_or(0);
let hi = line_starts
.get(idx + 1)
.copied()
.unwrap_or(source_len)
.min(source_len);
(lo, hi)
}
/// 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> {
@ -842,6 +937,15 @@ fn grammar_style_key(
})
}
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
@ -1292,30 +1396,9 @@ mod tests {
}
#[test]
fn current_line_range_finds_enclosing_line() {
// "abc\nde\nfgh": line_starts = [0, 4, 7]; source_len = 10.
let line_starts = vec![0u64, 4, 7];
let len = 10u64;
// Cursor at byte 0 → line 0 = [0, 4).
assert_eq!(current_line_range(&line_starts, len, 0), (0, 4));
// Cursor anywhere within line 0 → still line 0.
assert_eq!(current_line_range(&line_starts, len, 3), (0, 4));
// Cursor on the newline byte still belongs to the line it
// terminates.
assert_eq!(current_line_range(&line_starts, len, 3), (0, 4));
// Cursor at line 1 start → line 1 = [4, 7).
assert_eq!(current_line_range(&line_starts, len, 4), (4, 7));
// Cursor in last line → [7, len).
assert_eq!(current_line_range(&line_starts, len, 8), (7, 10));
// Cursor at exactly source_len (past last byte) → still last
// line; clamps cleanly without indexing out.
assert_eq!(current_line_range(&line_starts, len, len), (7, 10));
}
#[test]
fn current_line_projects_as_a_decoration_for_cursor_on_seed() {
// "abc\nde": cursor at byte 0 → CurrentLine = [0, 4).
fn semantic_projection_does_not_emit_current_line_decoration() {
// CurrentLine is a frontend-local visual for semantic sessions;
// the daemon should not copy the whole buffer to derive it.
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id);
@ -1324,23 +1407,14 @@ mod tests {
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
let current = decos
.iter()
.find(|d| d.kind == DecorationKind::CurrentLine)
.expect("CurrentLine present (cursor on line 0)");
assert_eq!(
current.range,
ByteRange { start: 0, end: 4 },
"line 0 of \"abc\\nde\" spans bytes [0, 4)"
assert!(
decos.iter().all(|d| d.kind != DecorationKind::CurrentLine),
"semantic projection must not emit CurrentLine; got {decos:?}"
);
}
#[test]
fn current_line_skipped_when_active_window_is_a_different_buffer() {
// Producer must only emit per-window state for windows whose
// active buffer matches the projected viewport. The vp.buffer_id
// regression test (decorations_use_vp_buffer_not_active_buffer)
// exercises this for Selection; assert it for CurrentLine too.
fn semantic_current_line_absence_does_not_depend_on_active_buffer() {
let state = empty_state();
let scratch_id = active_buffer(&state);
let file_id = {
@ -1358,17 +1432,15 @@ mod tests {
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert!(
decos.iter().all(|d| d.kind != DecorationKind::CurrentLine),
"CurrentLine must not project against a viewport whose buffer is not the active window's buffer; got {decos:?}"
"semantic projection must not emit CurrentLine for any viewport; got {decos:?}"
);
}
#[test]
fn same_line_cursor_motion_does_not_re_emit_decorations() {
// Q#3 stance β: horizontal cursor motion within the same line
// must not re-ship a Decorations frame. The existing M11.4
// changed_intervals diff gives this for free — same line means
// identical decoration ranges means an empty interval list
// means no emission.
fn cursor_motion_does_not_re_emit_decorations() {
// Cursor-only movement should not ship Decorations. Semantic
// frontends receive CursorByte separately and derive local
// cursor visuals without daemon decoration churn.
let state = empty_state();
let buffer_id = active_buffer(&state);
{
@ -1401,20 +1473,15 @@ mod tests {
"same-line cursor motion must not re-emit Decorations"
);
// Cross a `\n` (byte 10) → line changes → re-emission.
// Cross a `\n` (byte 10). Still no Decorations frame.
{
let mut core = state.core.borrow_mut();
core.active_window_mut().cursor = 12;
}
let msgs = s.render_frame(&state);
let (_full, decos) =
decorations_of(&msgs).expect("line-change must ship a Decorations frame");
let current = decos
.iter()
.find(|d| d.kind == DecorationKind::CurrentLine)
.expect("CurrentLine present");
// Line 1 of "abcdefghij\nklmno" starts at byte 11.
assert_eq!(current.range, ByteRange { start: 11, end: 16 });
assert!(
s.render_frame(&state).is_empty(),
"line-crossing cursor motion must not re-emit Decorations"
);
}
#[test]
@ -1428,11 +1495,8 @@ mod tests {
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
// Session 9.2 added `CurrentLine` to the projection: line 0
// (cursor at byte 0) emits as a `CurrentLine` decoration in
// addition to the seeded warning. This test pins the
// diagnostic projection's byte math; assert that decoration's
// shape rather than the total count.
// This test pins the diagnostic projection's byte math without
// relying on any cursor-line decoration.
let warning = decos
.iter()
.find(|d| d.kind == DecorationKind::DiagnosticWarning)
@ -1511,6 +1575,94 @@ mod tests {
);
}
/// Hold-while-stale (diagnostics churn fix): once diagnostics
/// have shipped, marking the store stale (which happens per edit)
/// must NOT ship a clearing frame — the frontend keeps its
/// last-received set, translated through its own local edits,
/// until the next `publishDiagnostics`. The pre-fix behavior
/// shipped a full empty frame on the first keystroke of every
/// burst (diagnostics blinked out, one full frontend reshape) and
/// re-added them after the next publish (blink in, another
/// reshape).
#[test]
fn diagnostics_hold_emission_while_store_stale_after_shipping() {
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("baseline ships the diagnostic");
assert!(
decos.iter().any(|d| is_diagnostic_kind(d.kind)),
"baseline contains the seeded diagnostic; got {decos:?}"
);
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());
assert!(
decorations_of(&s.render_frame(&state)).is_none(),
"stale store holds Decorations emission instead of shipping a clearing frame"
);
assert!(
decorations_of(&s.render_frame(&state)).is_none(),
"the hold is stable across frames"
);
// A selection change during the stale window still ships
// (without diagnostic kinds) — the hold must not pin a dead
// selection just to protect the diagnostics.
set_selection(&state, 0, 2);
let (_full, decos) = decorations_of(&s.render_frame(&state))
.expect("selection change ships during the stale window");
assert!(
decos.iter().any(|d| d.kind == DecorationKind::Selection),
"fresh selection present; got {decos:?}"
);
assert!(
decos.iter().all(|d| !is_diagnostic_kind(d.kind)),
"no stale-positioned diagnostics ride along; got {decos:?}"
);
// The next publishDiagnostics clears the flag; diagnostics
// re-emit on the following 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("post-publish frame re-ships diagnostics");
assert!(
decos
.iter()
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
"diagnostics return once the store is fresh; 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
@ -1648,6 +1800,7 @@ mod tests {
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
set_selection(&state, 0, 1);
let _ = s.render_frame(&state); // initial full
assert!(
s.render_frame(&state).is_empty(),
@ -1671,7 +1824,7 @@ mod tests {
}
// Generation transitioned → next frame must be full for both
// diff-shaped families.
// diff-shaped families when there is state to re-anchor.
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");
@ -1912,6 +2065,39 @@ mod tests {
sid
}
fn seed_rust_parse_view(
state: &EditorState,
buffer_id: BufferId,
text: &[u8],
) -> crate::syntax::ParseViewHandle {
let language = state
.syntax_registry
.language("rust")
.expect("rust language");
let mut core = state.core.borrow_mut();
let registry_handle = core.registry.clone();
let mut registry = registry_handle.borrow_mut();
let buf = registry.get_mut(buffer_id).expect("active buffer");
if !text.is_empty() {
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: text,
})
.expect("seed rust text");
}
let parse_view = crate::syntax::ParseView::new(buf, language, "rust".to_owned());
let handle = parse_view.handle();
let req = handle.make_request();
let bundle = crate::syntax::run_parse(req).expect("initial rust parse");
handle.install(std::sync::Arc::new(bundle));
buf.attach_view(Box::new(parse_view));
drop(registry);
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.rs")));
drop(core);
state.syntax_registry.attach_view(buffer_id, handle.clone());
handle
}
#[test]
fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() {
let state = empty_state();
@ -1953,6 +2139,123 @@ mod tests {
);
}
#[test]
fn grammar_style_spans_wait_for_pending_parse() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
let handle = seed_rust_parse_view(&state, bid, b"fn main() {}\n");
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
let first = s.render_frame(&state);
assert!(
style_segments(&first).is_some(),
"installed parse emits the baseline style frame"
);
{
let core = state.core.borrow();
core.registry
.borrow_mut()
.get_mut(bid)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"// editing\n",
})
.expect("typing edit");
}
assert!(
handle.pending_edit_count() > 0,
"attached parse view recorded the edit"
);
let pending = s.render_frame(&state);
assert!(
style_segments(&pending).is_none(),
"style query is skipped while edits are waiting for parse dispatch"
);
let req = handle.make_request();
state.syntax_registry.record_parse_job(9001, bid);
let in_flight = s.render_frame(&state);
assert!(
style_segments(&in_flight).is_none(),
"style query is skipped while the parse job is in flight"
);
let bundle = crate::syntax::run_parse(req).expect("settled rust parse");
handle.install(std::sync::Arc::new(bundle));
assert_eq!(state.syntax_registry.take_parse_job(9001), Some(bid));
let settled = s.render_frame(&state);
assert!(
style_segments(&settled).is_some(),
"new parse bundle emits refreshed style spans"
);
}
/// Hold-while-stale for the LSP-token styling authority: once a
/// grammar-less buffer's colors have shipped, marking the token
/// store stale (which happens per edit) must NOT ship a clearing
/// frame — the styling twin of the diagnostics hold. The next
/// token response clears the flag and re-emits.
#[test]
fn lsp_style_holds_while_token_store_stale() {
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,
);
assert!(
style_segments(&s.render_frame(&state)).is_some(),
"baseline ships the LSP-token styling"
);
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/x.cpp"));
{
let store = state.lsp_manager.borrow().semantic_token_store();
let mut guard = store.lock().expect("semantic token store");
guard.mark_stale(uri.clone());
}
assert!(
style_segments(&s.render_frame(&state)).is_none(),
"stale token store holds StyleSpans instead of clearing the colors"
);
assert!(
style_segments(&s.render_frame(&state)).is_none(),
"the hold is stable across frames"
);
// A fresh token response (absorbed via `set`) clears the flag.
// Identical tokens produce no frame — the frontend's cache was
// never cleared, so there is nothing to say. Changed tokens
// diff against the held baseline and ship.
set_tokens(&state, sid, vec![tok(0, 0, 3)]);
assert!(
style_segments(&s.render_frame(&state)).is_none(),
"fresh-but-identical tokens stay silent (cache was never wiped)"
);
set_tokens(&state, sid, vec![tok(0, 0, 5)]);
assert!(
style_segments(&s.render_frame(&state)).is_some(),
"fresh changed tokens re-emit the styling"
);
}
#[test]
fn lsp_style_suppressed_when_unchanged() {
let state = empty_state();
@ -2159,7 +2462,7 @@ mod tests {
}
#[test]
fn inline_adornments_emit_empty_clear_while_inlay_store_stale() {
fn inline_adornments_hold_while_inlay_store_stale() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
@ -2175,26 +2478,30 @@ mod tests {
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");
// Hold-while-stale: no frame at all. The frontend keeps its
// last-received hints, translated through its own local
// edits — an empty frame here would wipe them and visibly
// shift the line layout on the first keystroke of a burst.
assert!(
clear.is_empty(),
"stale hints must clear the frontend's cached virtual text"
adornments_of(&s.render_frame(&state)).is_none(),
"stale store holds emission (frontend keeps its translated cache)"
);
assert!(
adornments_of(&s.render_frame(&state)).is_none(),
"unchanged stale-empty state is suppressed after the clear"
"the hold is stable across frames"
);
set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]);
// A fresh inlayHint response clears the flag and re-emits at
// the server's (possibly shifted) positions.
set_inlay_store(&state, &uri, vec![hint(0, 7, ": 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);
assert_eq!(refreshed[0].at, 7);
}
#[cfg(feature = "crdt")]
#[test]
fn session8_temporal_probe_sustained_edits_clear_stale_inlays_until_refresh() {
fn session8_temporal_probe_sustained_edits_hold_stale_inlays_until_refresh() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
@ -2267,16 +2574,17 @@ mod tests {
}
assert_eq!(
clear_frames, 1,
"first stale frame clears cached hints; later stale frames stay silent"
clear_frames, 0,
"stale frames hold emission entirely — the frontend keeps \
its locally-translated hints instead of blinking them out"
);
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"
full_deco_frames, 0,
"empty Decorations state stays silent across generation transitions"
);
set_inlay_store(&state, &uri, vec![hint(0, 1005, ": i32")]);