diff --git a/src/diag.rs b/src/diag.rs index 4e29e63..e053fe0 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -167,9 +167,26 @@ impl Diagnostic { /// Per-URI diagnostic set. Mutated by [`crate::lsp::LspManager`] /// when handling `textDocument/publishDiagnostics`; read by /// [`DiagnosticView`] on every render. +/// +/// **T M11.8 — `stale_uris` tracking.** Per-URI flag set by +/// [`Self::mark_stale`] whenever the LSP layer ships a +/// `textDocument/didChange` for that URI; cleared by [`Self::set`] +/// when fresh diagnostics arrive. The semantic-frontend producer +/// reads [`Self::is_stale`] and suppresses decorations during the +/// window between an edit and clangd's republish, so a `pmacs-gpu` +/// (or any `semantic_render`) frontend never paints diagnostic +/// colors at byte positions that have since shifted under the +/// document. Closes the LSP-re-analysis-gap surface that bet #1 +/// in the session-4 framing pass predicted. #[derive(Default)] pub struct DiagnosticStore { by_uri: HashMap>, + /// URIs whose stored diagnostics are known to be out of date + /// because a `textDocument/didChange` was issued after the last + /// `publishDiagnostics` was absorbed. `Self::set` clears entries + /// here on the assumption that a fresh `publishDiagnostics` + /// corresponds to the latest sent version. + stale_uris: std::collections::HashSet, } impl DiagnosticStore { @@ -182,9 +199,14 @@ impl DiagnosticStore { /// Replace the diagnostic set for `uri`. Sorts by start position /// so [`Self::next_after`] / [`Self::previous_before`] don't /// have to re-sort on each query. + /// + /// Also clears the URI's stale flag (T M11.8) — fresh + /// diagnostics imply the LSP has caught up to the current + /// document state. pub fn set(&mut self, uri: impl Into, mut diags: Vec) { diags.sort_by(Diagnostic::compare_by_position); let uri = uri.into(); + self.stale_uris.remove(&uri); if diags.is_empty() { self.by_uri.remove(&uri); } else { @@ -192,9 +214,28 @@ impl DiagnosticStore { } } - /// Drop the diagnostics for `uri`. + /// Drop the diagnostics for `uri`. Also clears the stale flag — + /// no entry to be stale about. pub fn clear(&mut self, uri: &str) { self.by_uri.remove(uri); + self.stale_uris.remove(uri); + } + + /// Mark `uri`'s stored diagnostics as stale (T M11.8). Called + /// by the LSP layer on each `textDocument/didChange` so the + /// `semantic_render` producer can suppress emission during the + /// LSP-re-analysis gap. The next [`Self::set`] (or + /// [`Self::clear`]) clears the flag. + pub fn mark_stale(&mut self, uri: impl Into) { + self.stale_uris.insert(uri.into()); + } + + /// `true` iff the URI's stored diagnostics are stale (the + /// document has been edited since the last `publishDiagnostics` + /// absorption). T M11.8. + #[must_use] + pub fn is_stale(&self, uri: &str) -> bool { + self.stale_uris.contains(uri) } /// All diagnostics for `uri`, in start-position order. @@ -685,6 +726,51 @@ mod tests { assert_eq!(s.uris().count(), 0); } + // ---- T M11.8 stale-flag ---- + + #[test] + fn stale_flag_default_false() { + let s = DiagnosticStore::new(); + assert!(!s.is_stale("file:///a")); + } + + #[test] + fn mark_stale_sets_flag() { + let mut s = DiagnosticStore::new(); + s.mark_stale("file:///a"); + assert!(s.is_stale("file:///a")); + assert!(!s.is_stale("file:///b"), "stale flag is per-URI"); + } + + #[test] + fn set_clears_stale_flag() { + let mut s = DiagnosticStore::new(); + s.mark_stale("file:///a"); + assert!(s.is_stale("file:///a")); + s.set("file:///a", vec![diag(0, DiagnosticSeverity::Error, "x")]); + assert!(!s.is_stale("file:///a")); + } + + #[test] + fn empty_set_clears_stale_flag_too() { + // Empty diags after an edit means "LSP has caught up and + // there are no issues" — clear stale to allow rendering an + // empty decoration set. + let mut s = DiagnosticStore::new(); + s.mark_stale("file:///a"); + s.set("file:///a", Vec::new()); + assert!(!s.is_stale("file:///a")); + } + + #[test] + fn clear_drops_stale_flag() { + let mut s = DiagnosticStore::new(); + s.set("file:///a", vec![diag(0, DiagnosticSeverity::Error, "x")]); + s.mark_stale("file:///a"); + s.clear("file:///a"); + assert!(!s.is_stale("file:///a")); + } + #[test] fn severity_label_and_glyph_are_stable() { for (s, lbl, gl) in [ diff --git a/src/lsp.rs b/src/lsp.rs index 0ebc432..f778533 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -3044,6 +3044,15 @@ impl LspManager { let uri = uri.into(); let text = text.into(); self.documents.insert((sid, uri.clone()), text.clone()); + // T M11.8 — mark the diag-store entry stale so the + // semantic-frontend producer suppresses its emission until + // clangd's next `publishDiagnostics` re-establishes + // freshness via `set`. Closes the visible-stale-color + // window observed in session-5 manual validation. + self.diag_store + .lock() + .expect("diag store mutex poisoned") + .mark_stale(uri.clone()); let params = json!({ "textDocument": { "uri": uri, diff --git a/src/semantic_render.rs b/src/semantic_render.rs index f41153a..2ade9e2 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -390,13 +390,21 @@ impl SemanticRenderState { // 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 = { + 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.for_uri(&uri).to_vec(), guard.is_stale(&uri)) }; - if !diags.is_empty() { + if !is_stale && !diags.is_empty() { let registry = core.registry.clone(); let reg = registry.borrow(); if let Ok(buf) = reg.get(vp.buffer_id) { @@ -1124,6 +1132,76 @@ mod tests { 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