// diag.rs --- T M4.6 LSP-backed diagnostics: store + view. //! Diagnostic state and the [`DiagnosticView`] that renders it. //! //! Per spec §M4.6: a diagnostic is what an LSP reports for a buffer //! ("syntax error here", "unused variable there"). The //! [`DiagnosticStore`] holds the most recent set per URI, updated //! when [`crate::lsp::LspManager`] handles a //! `textDocument/publishDiagnostics` notification. The //! [`DiagnosticView`] composes over a buffer's text view to underline //! diagnostic ranges in severity-coloured wavy lines. //! //! # Why a separate module //! //! The store needs to be shared between the LSP layer (writer) and //! every attached view (reader), and the view's `Send` bound forces //! `Arc>`. Pulling this into its own module keeps `lsp.rs` //! focused on the protocol and gives the store a clean home that //! M4.7 (hover, completion, signature) and M4.8 (status surface) can //! reuse. //! //! # Position model //! //! LSP positions are `(line, character)` where `character` is in //! UTF-16 code units. For v0.1 we treat `character` as UTF-8 byte //! columns within the line --- that's wrong for buffers with //! non-BMP characters, but correct for the typical ASCII/BMP source //! files that drive the M4.6 acceptance gate. M5 may add a proper //! UTF-16 ↔ UTF-8 translation pass through the rope. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use serde_json::Value; use crate::buffer::Buffer; use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style, UnderlineStyle}; use crate::display_width::byte_range_to_columns; use crate::overlay::merge_styles; use crate::view::{View, Viewport}; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /// LSP diagnostic severity. `Error` is the most severe; `Hint` is /// the least. Mirrors the LSP `DiagnosticSeverity` enum with the /// same numeric values. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] pub enum DiagnosticSeverity { /// Compilation / parse error. Error = 1, /// Style or correctness warning. Warning = 2, /// Informational message. Information = 3, /// Hint (lowest severity). Hint = 4, } impl DiagnosticSeverity { /// Canonical short label for the modeline / status surface. #[must_use] pub fn label(self) -> &'static str { match self { Self::Error => "error", Self::Warning => "warning", Self::Information => "info", Self::Hint => "hint", } } /// One-letter gutter glyph. #[must_use] pub fn gutter_glyph(self) -> char { match self { Self::Error => 'E', Self::Warning => 'W', Self::Information => 'I', Self::Hint => 'H', } } /// Canonical severity color (T M4.6 / protocol v6): the /// `underline_color` of this severity's squiggle, the column-0 /// marker background, and the minimap mark all share it. Indexed /// 1/3/6/8 (red / yellow / cyan / gray) for 8/16-color terminal /// portability. #[must_use] pub fn underline_color(self) -> Color { match self { Self::Error => Color::Indexed(1), Self::Warning => Color::Indexed(3), Self::Information => Color::Indexed(6), Self::Hint => Color::Indexed(8), } } fn from_lsp_value(v: Option<&Value>) -> Self { match v.and_then(Value::as_i64) { Some(1) => Self::Error, Some(2) => Self::Warning, Some(3) => Self::Information, // 4 hint, missing → also hint per the spec's "implementation // free to choose" clause; pmacs picks Hint as the floor // so absent severity isn't conflated with Error. _ => Self::Hint, } } } /// One diagnostic, parsed from `textDocument/publishDiagnostics`. /// The `range` is half-open in LSP `(line, character)` coordinates. #[derive(Clone, Debug)] pub struct Diagnostic { /// First affected position. pub start_line: u32, /// First affected column (UTF-16 code units per LSP; pmacs v0.1 /// treats them as byte offsets within the line). pub start_col: u32, /// One-past-last affected position's line. pub end_line: u32, /// One-past-last affected position's column. pub end_col: u32, /// Severity bucket. pub severity: DiagnosticSeverity, /// Human-readable message. pub message: String, /// `source` field --- which linter / LSP produced this. Per the /// M4.6 acceptance, this must be visible. pub source: Option, /// Optional `code` field (e.g. `"E0308"`, `"unused-imports"`). pub code: Option, } impl Diagnostic { /// Parse a single diagnostic from an LSP JSON object. Returns /// `None` for objects that don't have the minimum required /// fields (range, message). #[must_use] pub fn from_lsp_value(v: &Value) -> Option { let range = v.get("range")?; let start = range.get("start")?; let end = range.get("end")?; let start_line = start.get("line")?.as_u64()? as u32; let start_col = start.get("character")?.as_u64()? as u32; let end_line = end.get("line")?.as_u64()? as u32; let end_col = end.get("character")?.as_u64()? as u32; let message = v.get("message")?.as_str()?.to_owned(); let severity = DiagnosticSeverity::from_lsp_value(v.get("severity")); let source = v.get("source").and_then(|s| s.as_str()).map(str::to_owned); let code = v.get("code").and_then(|c| { c.as_str() .map(str::to_owned) .or_else(|| c.as_i64().map(|n| n.to_string())) }); Some(Self { start_line, start_col, end_line, end_col, severity, message, source, code, }) } /// Compare two diagnostics by start position (row first, then /// column). Used to keep `next/previous` navigation deterministic. fn compare_by_position(&self, other: &Self) -> std::cmp::Ordering { self.start_line .cmp(&other.start_line) .then(self.start_col.cmp(&other.start_col)) } } // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- /// 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>, /// Per-URI severity totals, maintained alongside `by_uri` so /// frame-time consumers do not rescan every diagnostic at render /// cadence. Entries exist exactly when `by_uri` entries do. severity_counts: 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, /// Per-URI change counter, bumped on every [`Self::set`] / /// [`Self::clear`]. Diagnostics arrive without a CRDT generation /// bump, so generation-keyed caches (the `FileStyleSummary` /// producer's) additionally key on this to know a republish /// happened (T M4.6 GPU parity). epochs: HashMap, } fn count_severities(diags: &[Diagnostic]) -> (u32, u32, u32, u32) { let mut counts = (0u32, 0u32, 0u32, 0u32); for diagnostic in diags { let slot = match diagnostic.severity { DiagnosticSeverity::Error => &mut counts.0, DiagnosticSeverity::Warning => &mut counts.1, DiagnosticSeverity::Information => &mut counts.2, DiagnosticSeverity::Hint => &mut counts.3, }; *slot = slot.saturating_add(1); } counts } impl DiagnosticStore { /// Empty store. #[must_use] pub fn new() -> Self { Self::default() } /// 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(); let counts = count_severities(&diags); self.stale_uris.remove(&uri); *self.epochs.entry(uri.clone()).or_insert(0) += 1; if diags.is_empty() { self.by_uri.remove(&uri); self.severity_counts.remove(&uri); } else { self.severity_counts.insert(uri.clone(), counts); self.by_uri.insert(uri, diags); } } /// 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.severity_counts.remove(uri); self.stale_uris.remove(uri); *self.epochs.entry(uri.to_owned()).or_insert(0) += 1; } /// Drop **every** trace of `uri`, epoch included (dired Stage 2a, /// §5 finding 4). /// /// Distinct from [`Self::clear`] on purpose: `clear` *creates* an /// `epochs` entry (`or_insert(0) += 1`) because a consumer caching /// against the epoch must observe that the diagnostics went away. /// Forgetting is the opposite intent — the editor no longer holds /// this URI at all — so leaving the counter behind would be a /// URI-keyed leak in the one map nothing else prunes. pub fn forget(&mut self, uri: &str) { self.by_uri.remove(uri); self.severity_counts.remove(uri); self.stale_uris.remove(uri); self.epochs.remove(uri); } /// Monotonic per-URI change counter: how many times `set` / /// `clear` ran for this URI. `0` for a URI never written. /// Consumers cache against this to detect republishes that no /// CRDT generation bump announces. #[must_use] pub fn epoch_for(&self, uri: &str) -> u64 { self.epochs.get(uri).copied().unwrap_or(0) } /// 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. /// Returns an empty slice if the URI has none. #[must_use] pub fn for_uri(&self, uri: &str) -> &[Diagnostic] { self.by_uri.get(uri).map_or(&[], Vec::as_slice) } /// Number of diagnostics across all URIs at each severity. /// `(error, warning, info, hint)`. #[must_use] pub fn totals(&self) -> (usize, usize, usize, usize) { let mut e = 0; let mut w = 0; let mut i = 0; let mut h = 0; for &(errors, warnings, information, hints) in self.severity_counts.values() { e += errors as usize; w += warnings as usize; i += information as usize; h += hints as usize; } (e, w, i, h) } /// Per-URI totals `(error, warning, information, hint)`. /// /// The tuple is computed once by [`Self::set`] and deliberately /// survives [`Self::mark_stale`]: staleness invalidates byte /// positions, while the last published counts remain valid as a /// frozen status summary until the next publication. #[must_use] pub fn severity_counts_for(&self, uri: &str) -> (u32, u32, u32, u32) { self.severity_counts.get(uri).copied().unwrap_or_default() } /// Per-URI count. #[must_use] pub fn count_for(&self, uri: &str) -> usize { self.by_uri.get(uri).map_or(0, Vec::len) } /// All known URIs that have at least one diagnostic. pub fn uris(&self) -> impl Iterator { self.by_uri.keys().map(String::as_str) } /// First diagnostic in `uri` whose start is strictly past /// `(line, col)`. Returns `None` if no such diagnostic exists /// (caller may wrap to [`Self::first_for`]). #[must_use] pub fn next_after(&self, uri: &str, line: u32, col: u32) -> Option<&Diagnostic> { self.by_uri .get(uri)? .iter() .find(|d| d.start_line > line || (d.start_line == line && d.start_col > col)) } /// Last diagnostic in `uri` whose start is strictly before /// `(line, col)`. #[must_use] pub fn previous_before(&self, uri: &str, line: u32, col: u32) -> Option<&Diagnostic> { self.by_uri .get(uri)? .iter() .rev() .find(|d| d.start_line < line || (d.start_line == line && d.start_col < col)) } /// First diagnostic in `uri`, or `None` if none. #[must_use] pub fn first_for(&self, uri: &str) -> Option<&Diagnostic> { self.by_uri.get(uri)?.first() } /// Last diagnostic in `uri`, or `None` if none. #[must_use] pub fn last_for(&self, uri: &str) -> Option<&Diagnostic> { self.by_uri.get(uri)?.last() } } /// Cheaply-cloneable shared handle. Held by /// [`crate::lsp::LspManager`] (writer) and every /// [`DiagnosticView`] (reader). pub type SharedDiagStore = Arc>; /// Build a fresh shared store. #[must_use] pub fn make_shared_store() -> SharedDiagStore { Arc::new(Mutex::new(DiagnosticStore::new())) } // --------------------------------------------------------------------------- // View // --------------------------------------------------------------------------- /// The RESOLVED severity color (themes arc Q#TH5): the `ui.diag.*` /// face's `fg` when a face is set with a concrete color, else the /// built-in [`DiagnosticSeverity::underline_color`]. The diag family /// carries a special `Default` policy — `Default` fg means the /// built-in severity color, never "plain" — because the color doubles /// as the *presence* encoding in the minimap summary /// (`FileStyleSummary.underline_color`, where `Default` reads as "no /// mark"), so a plain severity color is unrepresentable and /// `ui.diag.error = {}` degrades to the built-in on every surface. #[must_use] pub fn severity_color( theme: Option<&crate::highlight::Theme>, severity: DiagnosticSeverity, ) -> Color { let name = match severity { DiagnosticSeverity::Error => "ui.diag.error", DiagnosticSeverity::Warning => "ui.diag.warning", DiagnosticSeverity::Information => "ui.diag.info", DiagnosticSeverity::Hint => "ui.diag.hint", }; match theme.and_then(|t| t.face(name)) { Some(f) if f.fg != Color::Default => f.fg, _ => severity.underline_color(), } } /// Style applied to bytes covered by a diagnostic: severity-shaped /// underline (wavy for error/warning, single for info, dotted for /// hint) colored via `underline_color` (not `fg`) so the squiggle /// reads its severity color while the syntax view's text color /// survives underneath (T M4.6, protocol v6). `color` is the /// resolved severity color ([`severity_color`]). fn style_for(severity: DiagnosticSeverity, color: Color) -> Style { let underline = match severity { DiagnosticSeverity::Error | DiagnosticSeverity::Warning => UnderlineStyle::Curly, DiagnosticSeverity::Information => UnderlineStyle::Single, DiagnosticSeverity::Hint => UnderlineStyle::Dotted, }; Style { underline, underline_color: color, ..Style::default() } } /// Style of the column-0 line marker — the TUI's gutter sign /// (T M4.6). The TUI reserves no gutter column, so the sign is a /// severity-colored *background* on the line's first cell: the /// glyph and its syntax color survive (the view contract is /// style-only), and zero-width diagnostics — invisible to the /// underline pass — still get a visible artifact. `color` is the /// resolved severity color ([`severity_color`]). fn marker_style_for(color: Color) -> Style { Style { bg: color, ..Style::default() } } /// View that consumes the shared diagnostic store and underlines /// affected ranges. Composes over [`crate::text_view::TextView`] per /// the M2 view-composition contract --- never writes glyphs, only /// merges underline styles into cells the base view has already /// painted. pub struct DiagnosticView { /// URI this view's diagnostics are keyed under. Set once at /// construction; M5 may add re-rooting if a buffer is renamed. uri: String, /// Shared store; mutated by the LSP manager, read by this view /// on every render. store: SharedDiagStore, /// Shared theme for the `ui.diag.*` face resolution (themes arc /// Q#TH9; the `SyntaxHighlightView` precedent). `None` — a bare /// test construction — paints the built-in severity colors. theme: Option, } impl DiagnosticView { /// Construct a diagnostic view for `uri` against `store`, /// resolving severity colors through `theme` when given. #[must_use] pub fn new( uri: impl Into, store: SharedDiagStore, theme: Option, ) -> Self { Self { uri: uri.into(), store, theme, } } /// The URI this view is keyed under. #[must_use] pub fn uri(&self) -> &str { &self.uri } } impl View for DiagnosticView { /// Re-root this view when the buffer's file was renamed (dired /// Stage 2a, §5). The URI field is private and `View` has no /// downcast, so this hook is the only way an outside sweep can /// reach it — and mutating in place preserves this overlay's /// position in the window's composition order. fn rename_resource(&mut self, old_uri: &str, new_uri: &str) { if self.uri == old_uri { new_uri.clone_into(&mut self.uri); } } fn kind(&self) -> &'static str { "diagnostic" } fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) { // Snapshot the diagnostics under the lock and drop it // immediately so we don't hold the lock through rendering // (rendering touches the rope, which could in principle // share the lock if the LSP layer ever re-enters --- it // doesn't today, but the discipline is cheap). let diags: Vec = { let guard = self.store.lock().expect("diag store mutex poisoned"); if guard.is_stale(&self.uri) { return; } guard.for_uri(&self.uri).to_vec() }; if diags.is_empty() { return; } // Compute a snapshot of the buffer's source so we can // translate (line, column) → display column with tab/UTF-8 // accounting. The rope keeps this cheap (refcount bump on // the root). M5 may switch to streaming row reads. let source: Vec = { let mut bytes = vec![0u8; buf.len() as usize]; if !bytes.is_empty() { buf.snapshot_rope().slice(0, buf.len(), &mut bytes); } bytes }; let line_offsets = compute_line_offsets(&source); let total_lines = line_offsets.len() as u32; let start_line_buf = line_at_offset(&line_offsets, viewport.buffer_start as u32); let max_rows = viewport.cell_size.rows; let max_cols = viewport.cell_size.cols; let cell_origin = viewport.cell_origin; // Column-0 line markers (gutter signs, T M4.6): most severe // diagnostic per visible row wins. `Ord` on the severity enum // follows LSP numbering, so "most severe" is the minimum. let mut line_markers: std::collections::HashMap = std::collections::HashMap::new(); // One theme clone per render (themes arc Q#TH9, the // SyntaxHighlightView discipline) for the ui.diag.* faces. let theme = self .theme .as_ref() .map(|t| t.lock().expect("theme mutex poisoned").clone()); for diag in &diags { let style = style_for(diag.severity, severity_color(theme.as_ref(), diag.severity)); // Apply to each line the diagnostic touches. LSP ranges // are half-open at the end position; if end_col == 0 // the diagnostic stops at the start of `end_line` so // we don't paint that line. let last_line = if diag.end_col == 0 && diag.end_line > diag.start_line { diag.end_line - 1 } else { diag.end_line }; for line in diag.start_line..=last_line { if line >= total_lines { break; } let row_offset = viewport.row_offset_of(start_line_buf as usize, line as usize); let Some(sign_row) = sign_row_for(viewport, start_line_buf, line) else { continue; }; if sign_row >= max_rows { break; } // Record the line marker before any byte-range work: // zero-width ranges (`byte_end <= byte_start` below) // skip the underline but still mark the line. line_markers .entry(sign_row) .and_modify(|s| *s = (*s).min(diag.severity)) .or_insert(diag.severity); let Some(row_offset) = row_offset else { continue; }; let line_start = line_offsets[line as usize]; let line_end = line_offsets .get(line as usize + 1) .copied() .unwrap_or(source.len() as u32); let line_end_no_nl = if line_end > line_start && source.get(line_end as usize - 1).copied() == Some(b'\n') { line_end - 1 } else { line_end }; let line_bytes = &source[line_start as usize..line_end_no_nl as usize]; let line_byte_len = line_bytes.len() as u32; // Resolve (start_col, end_col) within this line. let byte_start = if line == diag.start_line { diag.start_col.min(line_byte_len) } else { 0 }; let byte_end = if line == diag.end_line { diag.end_col.min(line_byte_len) } else { line_byte_len }; let (start_col, end_col) = underline_cols_for_line(line_bytes, byte_start, byte_end); if end_col <= start_col { continue; } let cell_row = cell_origin.row + row_offset; let clamped_start = start_col.min(max_cols); let clamped_end = end_col.min(max_cols); for col in clamped_start..clamped_end { let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col)); cell.style = merge_styles(cell.style, style); } } } // Paint the per-line severity markers last so one is visible even // when an underline span also touches the same cell. paint_line_markers( cells, cell_origin, viewport.gutter_w, max_cols, &line_markers, theme.as_ref(), ); } } /// The grid row a diagnostic on source `line` marks (Arc 6 Stage 2, /// Q#FD15). /// /// Its own row normally; when the line is collapsed away, the fold's /// **outermost visible head** row — a clamp, not a drop, so "there is a /// problem inside this collapsed region" survives the collapse (the /// most-severe-per-row merge then makes the head show the worst severity /// among itself and every line its fold hides). `None` when neither has /// a row in this viewport. Only the *sign* clamps: a squiggle needs a /// real row, so a hidden line contributes no underline. fn sign_row_for(viewport: Viewport<'_>, start_line: u32, line: u32) -> Option { viewport .row_offset_of(start_line as usize, line as usize) .or_else(|| { let map = viewport.folds?; viewport.row_offset_of(start_line as usize, map.visible_head_of(line as usize)) }) } /// Paint one severity marker per diagnostic line (UX gutter sub-arc 2). /// /// When the window reserves a gutter (`gutter_w > 0`), draw the severity /// *sign glyph* in the gutter's leading column (`cell_origin.col - /// gutter_w`, i.e. window column 0), colored by severity. Without a gutter, /// fall back to the legacy column-0 *background* marker on the line's first /// text cell — the "fake gutter" that predates a real gutter column. fn paint_line_markers( cells: &mut CellGrid<'_>, cell_origin: CellCoord, gutter_w: u32, max_cols: u32, line_markers: &std::collections::HashMap, theme: Option<&crate::highlight::Theme>, ) { for (&row_offset, &severity) in line_markers { let row = cell_origin.row + row_offset; let color = severity_color(theme, severity); if gutter_w > 0 { let cell = cells.at(CellCoord::new( row, cell_origin.col.saturating_sub(gutter_w), )); cell.glyph = Glyph::Char(severity.gutter_glyph()); cell.style = Style { fg: color, ..Style::default() }; } else if max_cols > 0 { let cell = cells.at(CellCoord::new(row, cell_origin.col)); cell.style = merge_styles(cell.style, marker_style_for(color)); } } } // --------------------------------------------------------------------------- // Line lookup helpers shared with the completion overlay. // --------------------------------------------------------------------------- pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec { let mut out = Vec::with_capacity(source.len() / 32 + 1); out.push(0); for (i, b) in source.iter().enumerate() { if *b == b'\n' { out.push(i as u32 + 1); } } out } pub(crate) fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 { match line_offsets.binary_search(&offset) { Ok(i) => i as u32, Err(i) => i.saturating_sub(1) as u32, } } /// Resolve the display-column span to underline for one line of a /// diagnostic. Zero-width ranges — the shape parsers use for /// "expected COMMA"-style errors anchored one past the last token /// (rust-analyzer reports a missing comma as `col 12 → col 12` at /// end of line, and the caller's `.min(line_byte_len)` clamps /// collapse any past-EOL anchor the same way) — get a single-cell /// span at the anchor: one past EOL is a blank cell inside the /// window, and a squiggled space is exactly how other editors /// surface it. fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) -> (u32, u32) { if byte_end <= byte_start { let (anchor, _) = byte_range_to_columns(line_bytes, byte_start as usize, byte_start as usize); (anchor, anchor + 1) } else { byte_range_to_columns(line_bytes, byte_start as usize, byte_end as usize) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use serde_json::json; fn diag(line: u32, sev: DiagnosticSeverity, msg: &str) -> Diagnostic { Diagnostic { start_line: line, start_col: 0, end_line: line, end_col: 5, severity: sev, message: msg.to_owned(), source: Some("test".to_owned()), code: None, } } /// dired Stage 2a §5, finding 4. `clear` *creates* an `epochs` /// entry, because a consumer caching against the epoch has to see /// that the diagnostics went away; nothing ever removes one. So a /// `forget_uri` that called `clear` would leave a URI-keyed leak /// behind in the one map nothing prunes — which is why the forget /// path is its own store method. #[test] fn forget_drops_the_epoch_while_clear_deliberately_bumps_it() { let mut store = DiagnosticStore::new(); store.set( "file:///a.rs", vec![diag(0, DiagnosticSeverity::Error, "boom")], ); store.mark_stale("file:///a.rs"); assert_eq!(store.epoch_for("file:///a.rs"), 1); store.clear("file:///a.rs"); assert_eq!( store.epoch_for("file:///a.rs"), 2, "clear announces the removal to epoch-keyed caches" ); store.set( "file:///a.rs", vec![diag(0, DiagnosticSeverity::Error, "boom")], ); store.mark_stale("file:///a.rs"); store.forget("file:///a.rs"); assert!(store.for_uri("file:///a.rs").is_empty(), "diagnostics"); assert!(!store.is_stale("file:///a.rs"), "stale flag"); assert_eq!( store.severity_counts_for("file:///a.rs"), (0, 0, 0, 0), "severity counts" ); assert_eq!( store.epoch_for("file:///a.rs"), 0, "forget leaves no trace at all, epoch included" ); } #[test] fn from_lsp_value_parses_minimal_diagnostic() { let v = json!({ "range": { "start": { "line": 1, "character": 4 }, "end": { "line": 1, "character": 9 }, }, "severity": 1, "message": "expected `;`", "source": "rust-analyzer", "code": "E0308" }); let d = Diagnostic::from_lsp_value(&v).expect("parse"); assert_eq!(d.start_line, 1); assert_eq!(d.start_col, 4); assert_eq!(d.end_line, 1); assert_eq!(d.end_col, 9); assert_eq!(d.severity, DiagnosticSeverity::Error); assert_eq!(d.message, "expected `;`"); assert_eq!(d.source.as_deref(), Some("rust-analyzer")); assert_eq!(d.code.as_deref(), Some("E0308")); } #[test] fn from_lsp_value_returns_none_when_required_fields_missing() { // Missing range. assert!(Diagnostic::from_lsp_value(&json!({"message": "x"})).is_none()); // Missing message. assert!( Diagnostic::from_lsp_value(&json!({ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } } })) .is_none() ); } #[test] fn severity_falls_back_to_hint_for_unknown_or_missing() { let v = json!({ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "message": "x" }); assert_eq!( Diagnostic::from_lsp_value(&v).unwrap().severity, DiagnosticSeverity::Hint ); } #[test] fn store_set_replaces_and_sorts() { let mut s = DiagnosticStore::new(); s.set( "file:///a", vec![ diag(5, DiagnosticSeverity::Error, "x"), diag(2, DiagnosticSeverity::Warning, "y"), ], ); let out = s.for_uri("file:///a"); assert_eq!(out.len(), 2); assert_eq!(out[0].start_line, 2, "should sort by line"); assert_eq!(out[1].start_line, 5); } #[test] fn store_clear_removes_uri() { let mut s = DiagnosticStore::new(); s.set( "file:///a", vec![diag(0, DiagnosticSeverity::Error, "boom")], ); s.clear("file:///a"); assert!(s.for_uri("file:///a").is_empty()); } #[test] fn next_after_finds_strictly_following_diagnostic() { let mut s = DiagnosticStore::new(); s.set( "file:///a", vec![ diag(2, DiagnosticSeverity::Warning, "y"), diag(5, DiagnosticSeverity::Error, "x"), diag(7, DiagnosticSeverity::Hint, "h"), ], ); // From line 0: first one. assert_eq!(s.next_after("file:///a", 0, 0).unwrap().start_line, 2); // From line 5 col 0: skip the one at (5, 0). assert_eq!(s.next_after("file:///a", 5, 0).unwrap().start_line, 7); // Past the last: None. assert!(s.next_after("file:///a", 99, 0).is_none()); } #[test] fn previous_before_finds_strictly_preceding_diagnostic() { let mut s = DiagnosticStore::new(); s.set( "file:///a", vec![ diag(2, DiagnosticSeverity::Warning, "y"), diag(5, DiagnosticSeverity::Error, "x"), ], ); assert_eq!(s.previous_before("file:///a", 99, 0).unwrap().start_line, 5); assert_eq!(s.previous_before("file:///a", 5, 0).unwrap().start_line, 2); assert!(s.previous_before("file:///a", 0, 0).is_none()); } #[test] fn totals_count_per_severity() { let mut store = DiagnosticStore::new(); store.set( "a", vec![ diag(0, DiagnosticSeverity::Error, "1"), diag(1, DiagnosticSeverity::Error, "2"), diag(2, DiagnosticSeverity::Warning, "3"), ], ); store.set( "b", vec![ diag(0, DiagnosticSeverity::Information, "4"), diag(1, DiagnosticSeverity::Hint, "5"), ], ); let totals = store.totals(); assert_eq!(totals, (2, 1, 1, 1)); } #[test] fn cached_severity_counts_replace_clear_and_survive_staleness() { let mut store = DiagnosticStore::new(); assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0)); store.set( "file:///a", vec![ diag(0, DiagnosticSeverity::Error, "1"), diag(1, DiagnosticSeverity::Error, "2"), diag(2, DiagnosticSeverity::Warning, "3"), diag(3, DiagnosticSeverity::Information, "4"), diag(4, DiagnosticSeverity::Hint, "5"), ], ); assert_eq!(store.severity_counts_for("file:///a"), (2, 1, 1, 1)); store.mark_stale("file:///a"); assert_eq!( store.severity_counts_for("file:///a"), (2, 1, 1, 1), "staleness freezes the last published counts" ); store.set( "file:///a", vec![diag(0, DiagnosticSeverity::Warning, "replacement")], ); assert_eq!(store.severity_counts_for("file:///a"), (0, 1, 0, 0)); store.clear("file:///a"); assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0)); } #[test] fn empty_set_clears_uri() { let mut s = DiagnosticStore::new(); s.set("a", vec![diag(0, DiagnosticSeverity::Error, "x")]); s.set("a", Vec::new()); assert!(s.for_uri("a").is_empty()); assert_eq!(s.severity_counts_for("a"), (0, 0, 0, 0)); 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 [ (DiagnosticSeverity::Error, "error", 'E'), (DiagnosticSeverity::Warning, "warning", 'W'), (DiagnosticSeverity::Information, "info", 'I'), (DiagnosticSeverity::Hint, "hint", 'H'), ] { assert_eq!(s.label(), lbl); assert_eq!(s.gutter_glyph(), gl); } } #[test] fn severity_styles_color_the_underline_not_the_text() { // T M4.6: each severity gets a distinct underline color via // `underline_color` (SGR 58); `fg` stays Default so the // syntax view's text color survives the merge. let mut seen = Vec::new(); for s in [ DiagnosticSeverity::Error, DiagnosticSeverity::Warning, DiagnosticSeverity::Information, DiagnosticSeverity::Hint, ] { let style = style_for(s, severity_color(None, s)); assert_eq!(style.fg, Color::Default, "{s:?} must not set fg"); assert_ne!( style.underline_color, Color::Default, "{s:?} must color its underline" ); assert!( !seen.contains(&style.underline_color), "{s:?} reuses another severity's underline color" ); seen.push(style.underline_color); } } #[test] fn epoch_bumps_on_set_and_clear_per_uri() { let mut store = DiagnosticStore::new(); assert_eq!(store.epoch_for("file:///a"), 0); store.set("file:///a", vec![diag(0, DiagnosticSeverity::Error, "x")]); assert_eq!(store.epoch_for("file:///a"), 1); // An empty set (server reports clean) still counts — the // consumer must refresh to drop its marks. store.set("file:///a", vec![]); assert_eq!(store.epoch_for("file:///a"), 2); store.clear("file:///a"); assert_eq!(store.epoch_for("file:///a"), 3); // mark_stale is not a content change; other URIs are isolated. store.mark_stale("file:///a"); assert_eq!(store.epoch_for("file:///a"), 3); assert_eq!(store.epoch_for("file:///b"), 0); } #[test] fn view_advertises_diagnostic_kind() { // `pmacs.window._overlay_kinds()` introspection (task #23 wire-up, // mirroring "syntax-highlight" / LspStyleView) relies on this. let store = make_shared_store(); let view = DiagnosticView::new("file:///a", store, None); assert_eq!(view.kind(), "diagnostic"); } #[test] fn diagnostic_view_suppresses_stale_store_entries() { use crate::cell::{Cell, CellSize, UnderlineStyle}; let store = make_shared_store(); { let mut guard = store.lock().expect("diag store"); guard.set( "file:///a", vec![diag(0, DiagnosticSeverity::Error, "stale")], ); guard.mark_stale("file:///a"); } let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.c"); buf.apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: b"hello\n", }) .expect("seed buffer"); let mut view = DiagnosticView::new("file:///a", store, None); let mut backing = vec![Cell::default(); 10]; let mut grid = CellGrid { cells: &mut backing, stride: 10, size: CellSize::new(1, 10), }; view.render( &buf, Viewport { buffer_start: 0, buffer_end: buf.len(), cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(1, 10), gutter_w: 0, folds: None, }, &mut grid, ); assert_eq!( grid.get(CellCoord::new(0, 0)).style.underline, UnderlineStyle::None, "stale diagnostics must not underline shifted TUI bytes" ); } #[test] fn column_zero_marker_shows_most_severe_diagnostic_per_line() { use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle}; let store = make_shared_store(); { let mut guard = store.lock().expect("diag store"); // Line 0: a Hint and an Error overlap — the marker must // show the Error. Line 1: a zero-width Warning (start == // end), invisible to the underline pass but still marked. guard.set( "file:///a", vec![ diag(0, DiagnosticSeverity::Hint, "h"), diag(0, DiagnosticSeverity::Error, "e"), Diagnostic { start_line: 1, start_col: 2, end_line: 1, end_col: 2, severity: DiagnosticSeverity::Warning, message: "w".to_owned(), source: None, code: None, }, ], ); } let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.c"); buf.apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: b"hello\nworld\nclean\n", }) .expect("seed buffer"); let mut view = DiagnosticView::new("file:///a", store, None); let mut backing = vec![Cell::default(); 30]; // Pre-paint glyphs at column 0 to pin the style-only contract. backing[0].glyph = Glyph::Char('h'); let mut grid = CellGrid { cells: &mut backing, stride: 10, size: CellSize::new(3, 10), }; view.render( &buf, Viewport { buffer_start: 0, buffer_end: buf.len(), cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(3, 10), gutter_w: 0, folds: None, }, &mut grid, ); // Line 0: red (error) marker wins over the hint's gray; the // glyph survives untouched. assert_eq!(grid.get(CellCoord::new(0, 0)).style.bg, Color::Indexed(1)); assert_eq!(grid.get(CellCoord::new(0, 0)).glyph, Glyph::Char('h')); // Line 1: zero-width warning gets a marker and a single-cell // squiggle at its anchor column — and only there. assert_eq!(grid.get(CellCoord::new(1, 0)).style.bg, Color::Indexed(3)); assert_eq!( grid.get(CellCoord::new(1, 2)).style.underline, UnderlineStyle::Curly ); assert_eq!( grid.get(CellCoord::new(1, 3)).style.underline, UnderlineStyle::None ); // Line 2: clean — no marker. assert_eq!(grid.get(CellCoord::new(2, 0)).style.bg, Color::Default); } #[test] fn gutter_sign_replaces_the_column_marker_when_a_gutter_is_reserved() { use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle}; let store = make_shared_store(); { let mut guard = store.lock().expect("diag store"); guard.set( "file:///a", vec![ // Line 0: Hint + Error overlap → the sign shows Error. diag(0, DiagnosticSeverity::Hint, "h"), diag(0, DiagnosticSeverity::Error, "e"), // Line 1: zero-width Warning (invisible to underline). Diagnostic { start_line: 1, start_col: 2, end_line: 1, end_col: 2, severity: DiagnosticSeverity::Warning, message: "w".to_owned(), source: None, code: None, }, ], ); } let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.c"); buf.apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: b"hello\nworld\nclean\n", }) .expect("seed buffer"); // A 2-cell gutter: text is shifted to column 2, signs land at // window column 0 (`cell_origin.col - gutter_w`). let mut view = DiagnosticView::new("file:///a", store, None); let mut backing = vec![Cell::default(); 30]; let mut grid = CellGrid { cells: &mut backing, stride: 10, size: CellSize::new(3, 10), }; view.render( &buf, Viewport { buffer_start: 0, buffer_end: buf.len(), cell_origin: CellCoord::new(0, 2), cell_size: CellSize::new(3, 8), gutter_w: 2, folds: None, }, &mut grid, ); // Line 0: Error sign glyph 'E' in red at the gutter's leading col. assert_eq!(grid.get(CellCoord::new(0, 0)).glyph, Glyph::Char('E')); assert_eq!(grid.get(CellCoord::new(0, 0)).style.fg, Color::Indexed(1)); // Line 1: Warning sign 'W' in yellow. assert_eq!(grid.get(CellCoord::new(1, 0)).glyph, Glyph::Char('W')); assert_eq!(grid.get(CellCoord::new(1, 0)).style.fg, Color::Indexed(3)); // The legacy background marker on the first *text* cell is NOT // painted when a gutter carries the sign instead. assert_eq!(grid.get(CellCoord::new(0, 2)).style.bg, Color::Default); // The squiggle still lands in the shifted text area (col 2 + 2). assert_eq!( grid.get(CellCoord::new(1, 4)).style.underline, UnderlineStyle::Curly ); // Line 2: clean — no sign glyph. assert_eq!(grid.get(CellCoord::new(2, 0)).glyph, Glyph::Char(' ')); } #[test] fn end_of_line_zero_width_error_squiggles_the_cell_past_eol() { // The missing-comma shape: rust-analyzer anchors "expected // COMMA" as a zero-width range one past the line's last // character (`b: 2` → col 12..12 on a 12-byte line). The // squiggle must land on the blank cell just past EOL, not // vanish in the empty-range clamp. use crate::cell::{Cell, CellSize, UnderlineStyle}; let store = make_shared_store(); store.lock().expect("diag store").set( "file:///a", vec![Diagnostic { start_line: 0, start_col: 5, // one past "hello" (5 bytes) end_line: 0, end_col: 5, severity: DiagnosticSeverity::Error, message: "expected COMMA".to_owned(), source: None, code: None, }], ); let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.rs"); buf.apply_edit(crate::buffer::EditOp::Insert { pos: 0, bytes: b"hello\nworld\n", }) .expect("seed buffer"); let mut view = DiagnosticView::new("file:///a", store, None); let mut backing = vec![Cell::default(); 20]; let mut grid = CellGrid { cells: &mut backing, stride: 10, size: CellSize::new(2, 10), }; view.render( &buf, Viewport { buffer_start: 0, buffer_end: buf.len(), cell_origin: CellCoord::new(0, 0), cell_size: CellSize::new(2, 10), gutter_w: 0, folds: None, }, &mut grid, ); // Single-cell red squiggle on the blank cell past "hello". let cell = grid.get(CellCoord::new(0, 5)); assert_eq!(cell.style.underline, UnderlineStyle::Curly); assert_eq!(cell.style.underline_color, Color::Indexed(1)); // Nothing under the word itself or beyond the anchor. assert_eq!( grid.get(CellCoord::new(0, 4)).style.underline, UnderlineStyle::None ); assert_eq!( grid.get(CellCoord::new(0, 6)).style.underline, UnderlineStyle::None ); } }