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:
parent
8ed15d86a1
commit
e7669c9d83
|
|
@ -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")]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue