Skip to main content

lattice_lsp/
diagnostics_layer.rs

1//! `DiagnosticsLayer` -- the editor's view of every server's
2//! `publishDiagnostics` events, keyed for multi-server merging
3//! and version-gated against stale publishes (Phase 4.1.d.ii).
4//!
5//! ## Why a separate layer instead of plumbing the bus directly
6//!
7//! The renderer and the `:diagnostics` buffer view both need:
8//!
9//! - **Per-URI lookup** of the latest set of diagnostics
10//!   (multi-server-merged when relevant).
11//! - **Per-line severity** -- what gutter glyph and what
12//!   underline color to draw on a given line.
13//! - **Workspace-wide enumeration** -- "show me every URI with
14//!   any diagnostic, sorted by severity".
15//!
16//! Walking the bus on every render-frame pull is the wrong
17//! shape; we'd need to replay the entire history. Instead the
18//! layer maintains a `HashMap<(Uri, Arc<str>), DiagState>` that
19//! the bus pump keeps up-to-date, and exposes O(n) lookups
20//! against current state.
21//!
22//! ## Multi-server scenarios
23//!
24//! Two servers can attach to the same buffer (rust-analyzer +
25//! a clippy linter bridge, c++ + a header-include linter).
26//! Each publishes independently. The layer keys by
27//! `(Uri, server_id)` so server A's clear doesn't drop
28//! server B's diagnostics on the same file.
29//!
30//! `diagnostics_for(uri)` merges across servers; the consumer
31//! sees one combined list.
32//!
33//! ## Version gating
34//!
35//! LSP servers may emit a `publishDiagnostics` for an older
36//! document version after the client has sent a newer
37//! `didChange`. Three rules:
38//!
39//! 1. If the incoming event's `version` is less than what we
40//!    already have for `(uri, server_id)`, drop it.
41//! 2. If `version` matches or is newer, accept.
42//! 3. If `version` is `None`, accept (server doesn't track
43//!    versions; we can't gate).
44//!
45//! Cross-version checking against the App's `DocSync.version()`
46//! is the App's job -- the layer only enforces internal
47//! monotonicity per `(uri, server_id)`.
48//!
49//! ## Empty-list = clear
50//!
51//! Per LSP spec, an empty `diagnostics` array means "the server
52//! cleared this URI's diagnostics". We remove the entry; we
53//! don't store an empty list (which would still appear in
54//! `iter_uris()`).
55
56use std::collections::{HashMap, HashSet};
57use std::sync::{Arc, Mutex};
58
59use arc_swap::ArcSwap;
60use lsp_types::{Diagnostic, DiagnosticSeverity, Uri};
61use tokio::sync::broadcast;
62
63use crate::diagnostics::DiagnosticEvent;
64use crate::logging::{LogLevel, LogSource, LspLogger};
65
66/// One server's latest published diagnostics for one URI.
67#[derive(Debug, Clone)]
68struct DiagState {
69    version: Option<i32>,
70    diagnostics: Arc<[Diagnostic]>,
71}
72
73/// Wait-free read view of all diagnostic state. Built by every
74/// write into the layer; held in `DiagnosticsLayer.snapshot`
75/// inside an `ArcSwap` cell. Renderers `load_full()` and read
76/// per-URI without ever touching a mutex -- the audit's C3
77/// finding (3000+ lock+clone+filter+collect per second on the
78/// render thread) is gone.
79#[derive(Debug, Clone, Default)]
80struct DiagnosticsSnapshot {
81    /// (uri, server_id) → state. Multi-server scenario: same
82    /// URI can have entries from multiple servers; the
83    /// `by_uri` field below pre-merges them so render-path
84    /// readers do one O(1) lookup + an Arc clone.
85    by_key: HashMap<(Uri, Arc<str>), DiagState>,
86    /// uri → merged-and-sorted-by-(line, column) diagnostics.
87    /// Recomputed for the affected URI on every write.
88    by_uri: HashMap<Uri, Arc<[Diagnostic]>>,
89}
90
91/// Subsystem-wide diagnostics state.
92///
93/// **Reads are wait-free.** [`Self::diagnostics_for`],
94/// [`Self::line_severity`], [`Self::diagnostics_on_line`], and
95/// the count helpers all go through one `ArcSwap::load`. The
96/// renderer's per-line `line_severity(uri, line)` call (50 lines
97/// × 60 Hz = 3000/s on the render thread per the audit) no
98/// longer takes a mutex.
99///
100/// **Writes serialise on a brief mutex.** [`Self::apply`] and the
101/// `clear_*` methods take a small write-side lock just long
102/// enough to clone the current snapshot, mutate it, recompute
103/// the affected URI's `by_uri` entry, and `ArcSwap::store` it.
104/// Writers don't block readers (ArcSwap is RCU-flavoured); two
105/// concurrent writers serialise via the write lock.
106///
107/// Cloneable; every clone shares the same `ArcSwap` cell + the
108/// same write lock (so the layer behaves as one logical state
109/// across actor pumps).
110#[derive(Clone, Default)]
111pub struct DiagnosticsLayer {
112    /// Wait-free read cell. RCU-flavoured: `load` is ~2ns;
113    /// `store` is one atomic release-store.
114    snapshot: Arc<ArcSwap<DiagnosticsSnapshot>>,
115    /// Serialises writers. Held only across the snapshot
116    /// clone + mutation + store -- microseconds, never across
117    /// I/O.
118    write: Arc<Mutex<()>>,
119    logger: LspLogger,
120    /// Render-wake fired after every `apply` (a server
121    /// `publishDiagnostics` push) so diagnostic CHANGES — including the
122    /// CLEAR when an error is fixed — repaint off-keystroke instead of
123    /// waiting for the next cursor-driven publish (the gutter,
124    /// underline, and inline summary all read this layer at render
125    /// time). `None` in tests / pre-boot; boot stores the editor's
126    /// `async_landed` via `set_wake`. Shared across clones (Arc), so
127    /// any actor pump's `apply` wakes the one render loop.
128    wake: Arc<arc_swap::ArcSwapOption<tokio::sync::Notify>>,
129}
130
131impl DiagnosticsLayer {
132    /// Build an empty layer. The logger is used for "dropped
133    /// stale" telemetry.
134    pub fn new(logger: LspLogger) -> Self {
135        Self {
136            snapshot: Arc::new(ArcSwap::from(Arc::new(DiagnosticsSnapshot::default()))),
137            write: Arc::new(Mutex::new(())),
138            logger,
139            wake: Arc::default(),
140        }
141    }
142
143    /// Install the render-wake `Notify` (the editor's `async_landed`).
144    /// Boot calls this once after the layer is built; every subsequent
145    /// `apply` fires it so a pushed diagnostic change repaints
146    /// off-keystroke. Idempotent (last wins); shared across all clones.
147    pub fn set_wake(&self, wake: Arc<tokio::sync::Notify>) {
148        self.wake.store(Some(wake));
149    }
150
151    /// §12 paint gate: a cheap change-token for the current snapshot.
152    ///
153    /// Every [`Self::apply`] swaps a fresh `Arc<DiagnosticsSnapshot>`
154    /// into the cell, so the snapshot's pointer address is a precise,
155    /// allocation-free "did the diagnostics change" signal — stable
156    /// while unchanged, distinct after any push (including the CLEAR
157    /// when an error is fixed). `build_render_state` folds this into
158    /// `RenderState::paint_revision` so a pushed diagnostics change
159    /// (which is an *overlay*, deliberately excluded from the cells
160    /// `MatrixVersion` — see `lattice-cells/src/version.rs`) still
161    /// requests an off-keystroke paint. Without it, the gutter /
162    /// underline / inline-summary would update in the snapshot but not
163    /// repaint until the next keypress (the "undo → diagnostic
164    /// disappears" report).
165    pub fn snapshot_revision(&self) -> usize {
166        Arc::as_ptr(&self.snapshot.load_full()) as *const () as usize
167    }
168
169    /// Apply one [`DiagnosticEvent`]. Drops stale events; clears
170    /// state on empty diagnostics; otherwise replaces the
171    /// `(uri, server_id)` entry. Single-snapshot publish at the
172    /// end: readers either see the prior state or the post-write
173    /// state, never a torn intermediate.
174    pub fn apply(&self, event: DiagnosticEvent) {
175        let key = (event.uri.clone(), Arc::clone(&event.server_id));
176        let _g = self.write.lock().expect("DiagnosticsLayer write lock");
177        let current = self.snapshot.load_full();
178
179        // Version gate: drop iff event.version < current and
180        // both are Some. Equal versions are accepted (server
181        // republishing is legal).
182        if let Some(prev) = current.by_key.get(&key)
183            && let (Some(p), Some(e)) = (prev.version, event.version)
184            && e < p
185        {
186            // Drop the lock guard before logging (logger may
187            // re-enter via Event::LspLogPushed on the bus).
188            drop(_g);
189            // B'.2: diagnostics layer doesn't carry workspace
190            // today; the stale-version trace is subsystem-level
191            // so route to the global ring rather than guessing
192            // the instance. Future slice can plumb workspace
193            // into `DiagnosticEvent` if per-instance routing is
194            // wanted.
195            self.logger.log(
196                None,
197                LogLevel::Debug,
198                LogSource::Client,
199                format!(
200                    "dropping stale diagnostics for {} from {} version {} (have {})",
201                    event.uri.as_str(),
202                    event.server_id,
203                    e,
204                    p
205                ),
206            );
207            return;
208        }
209
210        let mut next = (*current).clone();
211        if event.diagnostics.is_empty() {
212            next.by_key.remove(&key);
213        } else {
214            next.by_key.insert(
215                key.clone(),
216                DiagState {
217                    version: event.version,
218                    diagnostics: event.diagnostics,
219                },
220            );
221        }
222        rebuild_by_uri(&mut next, &key.0);
223        self.snapshot.store(Arc::new(next));
224        // Drop the write guard before the render-wake (notify_one is a
225        // bare atomic + doesn't re-enter the layer, but keep the lock
226        // window minimal). Wakes the render loop so this push — a new
227        // error OR the clear of a fixed one — repaints off-keystroke.
228        drop(_g);
229        if let Some(n) = self.wake.load_full() {
230            n.notify_one();
231        }
232    }
233
234    /// All diagnostics for a URI, merged across every server
235    /// that's attached to it. Returns an empty `Vec` when
236    /// there are none.
237    pub fn diagnostics_for(&self, uri: &Uri) -> Vec<Diagnostic> {
238        match self.snapshot.load().by_uri.get(uri) {
239            Some(arr) => arr.to_vec(),
240            None => Vec::new(),
241        }
242    }
243
244    /// Wait-free `Arc<[Diagnostic]>` borrow of the merged
245    /// diagnostics for `uri`. Preferred on the hot path
246    /// (renderer / picker fan-out) -- avoids the per-call
247    /// `Vec` allocation that `diagnostics_for` does.
248    pub fn diagnostics_arc(&self, uri: &Uri) -> Option<Arc<[Diagnostic]>> {
249        self.snapshot.load().by_uri.get(uri).cloned()
250    }
251
252    /// Diagnostics that overlap `line`. Half-open at the end:
253    /// a range ending on line N column 0 covers lines
254    /// `start.line ..= end.line - 1` (LSP convention). To match
255    /// the gutter glyph behaviour we still surface diagnostics
256    /// whose end is exactly at line N column 0 if N is the
257    /// current line -- callers prefer "show, don't hide" for
258    /// edge cases.
259    pub fn diagnostics_on_line(&self, uri: &Uri, line: u32) -> Vec<Diagnostic> {
260        let snap = self.snapshot.load();
261        match snap.by_uri.get(uri) {
262            Some(arr) => arr
263                .iter()
264                .filter(|d| d.range.start.line <= line && line <= d.range.end.line)
265                .cloned()
266                .collect(),
267            None => Vec::new(),
268        }
269    }
270
271    /// Most severe severity present on `line`, or `None` if no
272    /// diagnostic touches it. "Most severe" = lowest enum tag
273    /// (Error == 1 < Warning == 2 < Information == 3 <
274    /// Hint == 4). Used by the gutter glyph provider; runs once
275    /// per visible line per frame, so this path stays
276    /// allocation-free (filters the borrowed slice in place).
277    pub fn line_severity(&self, uri: &Uri, line: u32) -> Option<DiagnosticSeverity> {
278        let snap = self.snapshot.load();
279        snap.by_uri
280            .get(uri)?
281            .iter()
282            .filter(|d| d.range.start.line <= line && line <= d.range.end.line)
283            .filter_map(|d| d.severity)
284            .min_by_key(severity_rank)
285    }
286
287    /// L4a (lsp-architecture.md §15): a one-line end-of-line summary
288    /// of the qualifying diagnostics on `line` of `uri`, for the
289    /// inline cursor-line presentation. A diagnostic **qualifies**
290    /// when its severity rank is `<= min_rank` (as severe or more
291    /// severe than the configured floor); unknown-severity
292    /// diagnostics map to rank 4 and so only qualify when `min_rank`
293    /// is itself 4. Returns `None` when nothing on the line qualifies.
294    ///
295    /// The text is the **most-severe** qualifying diagnostic's message
296    /// (first line only, truncated to [`INLINE_SUMMARY_MAX_CHARS`] with
297    /// an ellipsis) plus ` +N` when `N` further qualifying diagnostics
298    /// share the line. Most-severe = lowest rank; ties resolve to the
299    /// earliest by `(line, character)` (the merged list is already so
300    /// ordered). Wait-free: one snapshot load + a single pass over the
301    /// URI's diagnostics, so it stays on the cheap side of the actor
302    /// thread's publish path.
303    pub fn inline_line_summary(
304        &self,
305        uri: &Uri,
306        line: u32,
307        min_rank: u8,
308    ) -> Option<InlineDiagnosticSummary> {
309        let snap = self.snapshot.load();
310        let arr = snap.by_uri.get(uri)?;
311        let mut best: Option<(u8, &Diagnostic)> = None;
312        let mut qualifying = 0usize;
313        for d in arr.iter() {
314            if d.range.start.line > line || line > d.range.end.line {
315                continue;
316            }
317            let rank = d.severity.as_ref().map(severity_rank).unwrap_or(4);
318            if rank > min_rank {
319                continue;
320            }
321            qualifying += 1;
322            // Replace only on a strictly-more-severe rank so ties keep
323            // the earlier (already (line, character)-ordered) entry.
324            if best.is_none_or(|(br, _)| rank < br) {
325                best = Some((rank, d));
326            }
327        }
328        let (rank, d) = best?;
329        let mut text = truncate_inline_message(&d.message);
330        let extra = qualifying - 1;
331        if extra > 0 {
332            use std::fmt::Write as _;
333            let _ = write!(text, " +{extra}");
334        }
335        Some(InlineDiagnosticSummary {
336            severity_rank: rank,
337            text,
338        })
339    }
340
341    /// Every URI with at least one stored diagnostic. Sorted
342    /// alphabetically (stable for the `:diagnostics` buffer
343    /// view).
344    pub fn iter_uris(&self) -> Vec<Uri> {
345        let snap = self.snapshot.load();
346        let mut uris: Vec<Uri> = snap
347            .by_uri
348            .keys()
349            .cloned()
350            .collect::<HashSet<_>>()
351            .into_iter()
352            .collect();
353        uris.sort_by(|a, b| a.as_str().cmp(b.as_str()));
354        uris
355    }
356
357    /// Snapshot of every (uri, merged-list) pair. Backs the
358    /// `:diagnostics` buffer's body. URIs sorted; diagnostics
359    /// within a URI ordered by (line, column).
360    pub fn snapshot(&self) -> Vec<(Uri, Vec<Diagnostic>)> {
361        let snap = self.snapshot.load();
362        let mut uris: Vec<Uri> = snap.by_uri.keys().cloned().collect();
363        uris.sort_by(|a, b| a.as_str().cmp(b.as_str()));
364        uris.into_iter()
365            .map(|uri| {
366                let diags = snap
367                    .by_uri
368                    .get(&uri)
369                    .map(|arr| arr.to_vec())
370                    .unwrap_or_default();
371                (uri, diags)
372            })
373            .collect()
374    }
375
376    /// Total diagnostic count across every URI / server. Used
377    /// by the modeline summary ("rust: 3 errors, 2 warnings").
378    pub fn count(&self) -> usize {
379        self.snapshot
380            .load()
381            .by_key
382            .values()
383            .map(|s| s.diagnostics.len())
384            .sum()
385    }
386
387    /// Severity tally across every URI: (errors, warnings,
388    /// info, hints). Modeline / status segment consumer.
389    pub fn severity_counts(&self) -> SeverityCounts {
390        let snap = self.snapshot.load();
391        let mut counts = SeverityCounts::default();
392        for state in snap.by_key.values() {
393            for d in state.diagnostics.iter() {
394                match d.severity {
395                    Some(DiagnosticSeverity::ERROR) => counts.errors += 1,
396                    Some(DiagnosticSeverity::WARNING) => counts.warnings += 1,
397                    Some(DiagnosticSeverity::INFORMATION) => counts.info += 1,
398                    Some(DiagnosticSeverity::HINT) => counts.hints += 1,
399                    _ => counts.unknown += 1,
400                }
401            }
402        }
403        counts
404    }
405
406    /// Drop everything. Used by `:diag-clear` and tests.
407    pub fn clear(&self) {
408        let _g = self.write.lock().expect("DiagnosticsLayer write lock");
409        self.snapshot
410            .store(Arc::new(DiagnosticsSnapshot::default()));
411    }
412
413    /// Drop every entry for a URI (across all servers). Used
414    /// when a buffer closes -- the diagnostics are no longer
415    /// relevant since we won't render them.
416    pub fn clear_uri(&self, uri: &Uri) {
417        let _g = self.write.lock().expect("DiagnosticsLayer write lock");
418        let current = self.snapshot.load_full();
419        let mut next = (*current).clone();
420        next.by_key.retain(|(u, _), _| u != uri);
421        next.by_uri.remove(uri);
422        self.snapshot.store(Arc::new(next));
423    }
424
425    /// Drop every entry for one server -- used when a server
426    /// dies / is detached.
427    pub fn clear_server(&self, server_id: &Arc<str>) {
428        let _g = self.write.lock().expect("DiagnosticsLayer write lock");
429        let current = self.snapshot.load_full();
430        let mut next = (*current).clone();
431        let affected_uris: Vec<Uri> = next
432            .by_key
433            .keys()
434            .filter(|(_, s)| Arc::ptr_eq(s, server_id) || s.as_ref() == server_id.as_ref())
435            .map(|(u, _)| u.clone())
436            .collect();
437        next.by_key
438            .retain(|(_, s), _| !Arc::ptr_eq(s, server_id) && s.as_ref() != server_id.as_ref());
439        for uri in affected_uris {
440            rebuild_by_uri(&mut next, &uri);
441        }
442        self.snapshot.store(Arc::new(next));
443    }
444}
445
446/// Recompute `next.by_uri[uri]` from `next.by_key`. Called
447/// inside the write critical section after every mutation that
448/// could have changed the affected URI's merged-list. Sort key
449/// is `(line, character)` so the picker / `:diagnostics` view
450/// gets stable ordering without sorting at read time.
451fn rebuild_by_uri(next: &mut DiagnosticsSnapshot, uri: &Uri) {
452    let mut merged: Vec<Diagnostic> = next
453        .by_key
454        .iter()
455        .filter(|((u, _), _)| u == uri)
456        .flat_map(|(_, s)| s.diagnostics.iter().cloned())
457        .collect();
458    if merged.is_empty() {
459        next.by_uri.remove(uri);
460        return;
461    }
462    merged.sort_by(|a, b| {
463        a.range
464            .start
465            .line
466            .cmp(&b.range.start.line)
467            .then(a.range.start.character.cmp(&b.range.start.character))
468    });
469    next.by_uri.insert(uri.clone(), Arc::from(merged));
470}
471
472impl std::fmt::Debug for DiagnosticsLayer {
473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474        let n = self.count();
475        f.debug_struct("DiagnosticsLayer")
476            .field("total_diagnostics", &n)
477            .finish_non_exhaustive()
478    }
479}
480
481/// Counts of each diagnostic severity in the layer.
482#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
483pub struct SeverityCounts {
484    pub errors: usize,
485    pub warnings: usize,
486    pub info: usize,
487    pub hints: usize,
488    /// Diagnostics with `severity = None` (servers may omit it
489    /// for "general advisory" entries).
490    pub unknown: usize,
491}
492
493impl SeverityCounts {
494    /// Total across all severities.
495    pub fn total(&self) -> usize {
496        self.errors + self.warnings + self.info + self.hints + self.unknown
497    }
498}
499
500/// Map LSP severity to a sort rank (lower = more severe).
501fn severity_rank(s: &DiagnosticSeverity) -> u8 {
502    // DiagnosticSeverity is a wrapper struct around i32; pull
503    // out the int and compare. Spec: 1=Error, 2=Warning,
504    // 3=Information, 4=Hint.
505    match *s {
506        DiagnosticSeverity::ERROR => 0,
507        DiagnosticSeverity::WARNING => 1,
508        DiagnosticSeverity::INFORMATION => 2,
509        DiagnosticSeverity::HINT => 3,
510        _ => 4,
511    }
512}
513
514/// Char budget for the inline summary message before it is cut and
515/// an ellipsis appended. Counted on `char` boundaries so multibyte
516/// text is never split mid-codepoint.
517const INLINE_SUMMARY_MAX_CHARS: usize = 80;
518
519/// First line of `msg`, trimmed and truncated to
520/// [`INLINE_SUMMARY_MAX_CHARS`] with a trailing `…`. Multi-line
521/// diagnostic messages (e.g. rustc with notes) collapse to their
522/// first line for the one-line inline summary.
523fn truncate_inline_message(msg: &str) -> String {
524    let first = msg.lines().next().unwrap_or("").trim();
525    if first.chars().count() <= INLINE_SUMMARY_MAX_CHARS {
526        return first.to_string();
527    }
528    let truncated: String = first.chars().take(INLINE_SUMMARY_MAX_CHARS).collect();
529    format!("{truncated}…")
530}
531
532/// L4a (lsp-architecture.md §15): a one-line end-of-line summary of
533/// the diagnostics on a single line, produced by
534/// [`DiagnosticsLayer::inline_line_summary`] for the inline
535/// cursor-line presentation. The renderer (L4a.3) splices `text` as
536/// trailing virtual text and themes it by `severity_rank`.
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct InlineDiagnosticSummary {
539    /// Severity rank of the most-severe diagnostic on the line
540    /// (Error = 0 … Hint = 3, unknown = 4), matching
541    /// [`severity_rank`]. The renderer maps this to the themed
542    /// severity colour.
543    pub severity_rank: u8,
544    /// Pre-formatted summary text: the most-severe message (first
545    /// line, truncated) plus ` +N` when more diagnostics qualify.
546    pub text: String,
547}
548
549/// Drain a `DiagnosticEvent` broadcast receiver into the layer
550/// in a tokio task. Returns when the bus closes (the server is
551/// gone). Lagging consumers (`Lagged(n)`) are tolerated --
552/// the next event in the queue still reflects the latest
553/// state per URI, which is what callers care about.
554///
555/// Spawn pattern:
556///
557/// ```ignore
558/// let layer = DiagnosticsLayer::new(logger.clone());
559/// let rx = server_handle.subscribe_diagnostics();
560/// tokio::spawn(pump_diagnostics(layer.clone(), rx));
561/// ```
562pub async fn pump_diagnostics(
563    layer: DiagnosticsLayer,
564    mut rx: broadcast::Receiver<DiagnosticEvent>,
565) {
566    loop {
567        match rx.recv().await {
568            Ok(event) => layer.apply(event),
569            Err(broadcast::error::RecvError::Lagged(_)) => {
570                // Drop the lag; latest state still arrives.
571            }
572            Err(broadcast::error::RecvError::Closed) => return,
573        }
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use lsp_types::{Position, Range};
581    use std::str::FromStr;
582
583    fn diag(line: u32, severity: DiagnosticSeverity, msg: &str) -> Diagnostic {
584        Diagnostic {
585            range: Range {
586                start: Position { line, character: 0 },
587                end: Position { line, character: 5 },
588            },
589            severity: Some(severity),
590            code: None,
591            code_description: None,
592            source: None,
593            message: msg.into(),
594            related_information: None,
595            tags: None,
596            data: None,
597        }
598    }
599
600    fn ev(
601        server: &str,
602        uri: &str,
603        version: Option<i32>,
604        diags: Vec<Diagnostic>,
605    ) -> DiagnosticEvent {
606        DiagnosticEvent {
607            server_id: Arc::from(server),
608            uri: Uri::from_str(uri).unwrap(),
609            version,
610            diagnostics: Arc::from(diags.into_boxed_slice()),
611        }
612    }
613
614    fn layer() -> DiagnosticsLayer {
615        DiagnosticsLayer::new(LspLogger::with_defaults())
616    }
617
618    #[test]
619    fn apply_stores_diagnostics() {
620        let l = layer();
621        l.apply(ev(
622            "rust",
623            "file:///x.rs",
624            Some(1),
625            vec![diag(0, DiagnosticSeverity::ERROR, "boom")],
626        ));
627        let uri = Uri::from_str("file:///x.rs").unwrap();
628        let d = l.diagnostics_for(&uri);
629        assert_eq!(d.len(), 1);
630        assert_eq!(d[0].message, "boom");
631    }
632
633    /// §12 paint gate: `snapshot_revision` is stable while unchanged and
634    /// moves on every push (including the CLEAR). `build_render_state`
635    /// folds it into `RenderState::paint_revision`, so an off-keystroke
636    /// diagnostics change — an OVERLAY, excluded from the cells
637    /// `MatrixVersion` — still requests a paint. This is the
638    /// "undo → diagnostic disappears" fix: the server re-publishes after
639    /// the undo's didChange and the gutter/underline/summary repaint
640    /// without a keypress.
641    #[test]
642    fn snapshot_revision_changes_on_apply_stable_otherwise() {
643        let l = layer();
644        let r0 = l.snapshot_revision();
645        // No push → identical revision → a no-op publish won't paint.
646        assert_eq!(
647            r0,
648            l.snapshot_revision(),
649            "revision must be stable while unchanged"
650        );
651        // A server push swaps the snapshot Arc → revision moves.
652        l.apply(ev(
653            "rust",
654            "file:///x.rs",
655            Some(1),
656            vec![diag(0, DiagnosticSeverity::ERROR, "boom")],
657        ));
658        let r1 = l.snapshot_revision();
659        assert_ne!(
660            r0, r1,
661            "a diagnostics push must change the snapshot revision"
662        );
663        // The CLEAR (error fixed) is also a change.
664        l.apply(ev("rust", "file:///x.rs", Some(2), vec![]));
665        assert_ne!(
666            r1,
667            l.snapshot_revision(),
668            "clearing diagnostics must change the snapshot revision"
669        );
670    }
671
672    #[tokio::test]
673    async fn apply_fires_the_render_wake() {
674        let l = layer();
675        let notify = std::sync::Arc::new(tokio::sync::Notify::new());
676        l.set_wake(notify.clone());
677        // `apply` (a server push) must wake the render loop so the
678        // change repaints off-keystroke. notify_one before notified()
679        // stores a permit, so the await resolves.
680        l.apply(ev(
681            "rust",
682            "file:///x.rs",
683            Some(1),
684            vec![diag(0, DiagnosticSeverity::ERROR, "boom")],
685        ));
686        tokio::time::timeout(std::time::Duration::from_millis(200), notify.notified())
687            .await
688            .expect("apply should fire the render-wake");
689    }
690
691    #[test]
692    fn apply_with_empty_list_clears_entry() {
693        let l = layer();
694        l.apply(ev(
695            "rust",
696            "file:///x.rs",
697            Some(1),
698            vec![diag(0, DiagnosticSeverity::ERROR, "first")],
699        ));
700        l.apply(ev("rust", "file:///x.rs", Some(2), Vec::new()));
701        let uri = Uri::from_str("file:///x.rs").unwrap();
702        assert!(l.diagnostics_for(&uri).is_empty());
703        assert!(l.iter_uris().is_empty(), "URI gone after clear");
704    }
705
706    #[test]
707    fn stale_version_is_dropped() {
708        let l = layer();
709        l.apply(ev(
710            "rust",
711            "file:///x.rs",
712            Some(5),
713            vec![diag(0, DiagnosticSeverity::ERROR, "current")],
714        ));
715        // Stale: older version, should not overwrite.
716        l.apply(ev(
717            "rust",
718            "file:///x.rs",
719            Some(3),
720            vec![diag(0, DiagnosticSeverity::ERROR, "stale")],
721        ));
722        let uri = Uri::from_str("file:///x.rs").unwrap();
723        let d = l.diagnostics_for(&uri);
724        assert_eq!(d.len(), 1);
725        assert_eq!(d[0].message, "current");
726    }
727
728    #[test]
729    fn equal_version_replaces_state() {
730        let l = layer();
731        l.apply(ev(
732            "rust",
733            "file:///x.rs",
734            Some(5),
735            vec![diag(0, DiagnosticSeverity::ERROR, "first")],
736        ));
737        l.apply(ev(
738            "rust",
739            "file:///x.rs",
740            Some(5),
741            vec![diag(0, DiagnosticSeverity::ERROR, "republish")],
742        ));
743        let uri = Uri::from_str("file:///x.rs").unwrap();
744        let d = l.diagnostics_for(&uri);
745        assert_eq!(d.len(), 1);
746        assert_eq!(d[0].message, "republish");
747    }
748
749    #[test]
750    fn version_none_always_accepted() {
751        let l = layer();
752        l.apply(ev(
753            "rust",
754            "file:///x.rs",
755            None,
756            vec![diag(0, DiagnosticSeverity::ERROR, "first")],
757        ));
758        l.apply(ev(
759            "rust",
760            "file:///x.rs",
761            None,
762            vec![diag(0, DiagnosticSeverity::WARNING, "second")],
763        ));
764        let uri = Uri::from_str("file:///x.rs").unwrap();
765        let d = l.diagnostics_for(&uri);
766        assert_eq!(d.len(), 1);
767        assert_eq!(d[0].message, "second");
768    }
769
770    #[test]
771    fn multi_server_diagnostics_merge_per_uri() {
772        let l = layer();
773        l.apply(ev(
774            "rust",
775            "file:///x.rs",
776            Some(1),
777            vec![diag(0, DiagnosticSeverity::ERROR, "rust err")],
778        ));
779        l.apply(ev(
780            "clippy",
781            "file:///x.rs",
782            Some(1),
783            vec![diag(2, DiagnosticSeverity::WARNING, "clippy warn")],
784        ));
785        let uri = Uri::from_str("file:///x.rs").unwrap();
786        let d = l.diagnostics_for(&uri);
787        assert_eq!(d.len(), 2);
788        let messages: Vec<&str> = d.iter().map(|x| x.message.as_str()).collect();
789        assert!(messages.contains(&"rust err"));
790        assert!(messages.contains(&"clippy warn"));
791    }
792
793    #[test]
794    fn one_servers_clear_does_not_drop_anothers_diagnostics() {
795        let l = layer();
796        l.apply(ev(
797            "rust",
798            "file:///x.rs",
799            Some(1),
800            vec![diag(0, DiagnosticSeverity::ERROR, "rust")],
801        ));
802        l.apply(ev(
803            "clippy",
804            "file:///x.rs",
805            Some(1),
806            vec![diag(2, DiagnosticSeverity::WARNING, "clippy")],
807        ));
808        // rust clears its diagnostics for the URI.
809        l.apply(ev("rust", "file:///x.rs", Some(2), Vec::new()));
810        let uri = Uri::from_str("file:///x.rs").unwrap();
811        let d = l.diagnostics_for(&uri);
812        assert_eq!(d.len(), 1);
813        assert_eq!(d[0].message, "clippy");
814    }
815
816    #[test]
817    fn line_severity_returns_most_severe() {
818        let l = layer();
819        l.apply(ev(
820            "rust",
821            "file:///x.rs",
822            None,
823            vec![
824                diag(2, DiagnosticSeverity::WARNING, "warn"),
825                diag(2, DiagnosticSeverity::ERROR, "err"),
826                diag(2, DiagnosticSeverity::HINT, "hint"),
827            ],
828        ));
829        let uri = Uri::from_str("file:///x.rs").unwrap();
830        assert_eq!(l.line_severity(&uri, 2), Some(DiagnosticSeverity::ERROR));
831    }
832
833    #[test]
834    fn line_severity_returns_none_for_clean_lines() {
835        let l = layer();
836        l.apply(ev(
837            "rust",
838            "file:///x.rs",
839            None,
840            vec![diag(0, DiagnosticSeverity::ERROR, "boom")],
841        ));
842        let uri = Uri::from_str("file:///x.rs").unwrap();
843        assert_eq!(l.line_severity(&uri, 5), None);
844    }
845
846    #[test]
847    fn inline_summary_picks_most_severe_and_counts_extras() {
848        let l = layer();
849        l.apply(ev(
850            "rust",
851            "file:///x.rs",
852            None,
853            vec![
854                diag(2, DiagnosticSeverity::WARNING, "warn"),
855                diag(2, DiagnosticSeverity::ERROR, "the error"),
856                diag(2, DiagnosticSeverity::HINT, "hint"),
857            ],
858        ));
859        let uri = Uri::from_str("file:///x.rs").unwrap();
860        // min_rank = 3 (hint floor) → all three qualify; most-severe
861        // is the error, with two further diagnostics on the line.
862        let s = l.inline_line_summary(&uri, 2, 3).unwrap();
863        assert_eq!(s.severity_rank, 0);
864        assert_eq!(s.text, "the error +2");
865    }
866
867    #[test]
868    fn inline_summary_filters_by_min_severity() {
869        let l = layer();
870        l.apply(ev(
871            "rust",
872            "file:///x.rs",
873            None,
874            vec![
875                diag(2, DiagnosticSeverity::WARNING, "warn"),
876                diag(2, DiagnosticSeverity::HINT, "hint"),
877            ],
878        ));
879        let uri = Uri::from_str("file:///x.rs").unwrap();
880        // min_rank = 1 (warning floor) → only the warning qualifies;
881        // the hint is excluded, so no `+N` suffix.
882        let s = l.inline_line_summary(&uri, 2, 1).unwrap();
883        assert_eq!(s.severity_rank, 1);
884        assert_eq!(s.text, "warn");
885        // min_rank = 0 (error floor) → nothing on the line qualifies.
886        assert!(l.inline_line_summary(&uri, 2, 0).is_none());
887    }
888
889    #[test]
890    fn inline_summary_none_on_clean_line() {
891        let l = layer();
892        l.apply(ev(
893            "rust",
894            "file:///x.rs",
895            None,
896            vec![diag(0, DiagnosticSeverity::ERROR, "boom")],
897        ));
898        let uri = Uri::from_str("file:///x.rs").unwrap();
899        assert!(l.inline_line_summary(&uri, 5, 3).is_none());
900    }
901
902    #[test]
903    fn inline_summary_truncates_long_first_line() {
904        let long = "x".repeat(INLINE_SUMMARY_MAX_CHARS + 40);
905        let multi = format!("{long}\nsecond line that must be dropped");
906        let l = layer();
907        l.apply(ev(
908            "rust",
909            "file:///x.rs",
910            None,
911            vec![diag(1, DiagnosticSeverity::ERROR, &multi)],
912        ));
913        let uri = Uri::from_str("file:///x.rs").unwrap();
914        let s = l.inline_line_summary(&uri, 1, 3).unwrap();
915        // First line only, cut to the budget + an ellipsis.
916        assert_eq!(s.text.chars().count(), INLINE_SUMMARY_MAX_CHARS + 1);
917        assert!(s.text.ends_with('…'));
918        assert!(!s.text.contains("second line"));
919    }
920
921    #[test]
922    fn inline_summary_tie_keeps_earliest() {
923        let l = layer();
924        l.apply(ev(
925            "rust",
926            "file:///x.rs",
927            None,
928            vec![
929                diag(3, DiagnosticSeverity::ERROR, "first error"),
930                diag(3, DiagnosticSeverity::ERROR, "second error"),
931            ],
932        ));
933        let uri = Uri::from_str("file:///x.rs").unwrap();
934        let s = l.inline_line_summary(&uri, 3, 3).unwrap();
935        assert_eq!(s.text, "first error +1");
936    }
937
938    #[test]
939    fn diagnostics_on_line_filters_by_range() {
940        let mut d_multi = diag(2, DiagnosticSeverity::ERROR, "spans");
941        d_multi.range.end = Position {
942            line: 4,
943            character: 0,
944        };
945        let l = layer();
946        l.apply(ev("rust", "file:///x.rs", None, vec![d_multi]));
947        let uri = Uri::from_str("file:///x.rs").unwrap();
948        // Lines 2..=4 are in range.
949        for line in 2..=4 {
950            assert_eq!(l.diagnostics_on_line(&uri, line).len(), 1, "line {line}");
951        }
952        // Line 5 is outside.
953        assert!(l.diagnostics_on_line(&uri, 5).is_empty());
954    }
955
956    #[test]
957    fn iter_uris_sorts_alphabetically() {
958        let l = layer();
959        l.apply(ev(
960            "rust",
961            "file:///b.rs",
962            None,
963            vec![diag(0, DiagnosticSeverity::ERROR, "x")],
964        ));
965        l.apply(ev(
966            "rust",
967            "file:///a.rs",
968            None,
969            vec![diag(0, DiagnosticSeverity::ERROR, "x")],
970        ));
971        let uris = l.iter_uris();
972        assert_eq!(uris.len(), 2);
973        assert_eq!(uris[0].as_str(), "file:///a.rs");
974        assert_eq!(uris[1].as_str(), "file:///b.rs");
975    }
976
977    #[test]
978    fn snapshot_returns_per_uri_sorted_by_line_then_column() {
979        let l = layer();
980        let mut d_a = diag(0, DiagnosticSeverity::ERROR, "first");
981        d_a.range.start = Position {
982            line: 5,
983            character: 10,
984        };
985        let mut d_b = diag(0, DiagnosticSeverity::ERROR, "second");
986        d_b.range.start = Position {
987            line: 5,
988            character: 3,
989        };
990        let mut d_c = diag(0, DiagnosticSeverity::ERROR, "third");
991        d_c.range.start = Position {
992            line: 1,
993            character: 0,
994        };
995        l.apply(ev("rust", "file:///x.rs", None, vec![d_a, d_b, d_c]));
996        let snap = l.snapshot();
997        assert_eq!(snap.len(), 1);
998        let (_, diags) = &snap[0];
999        let messages: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
1000        // Expected order: line 1 "third", line 5 col 3 "second", line 5 col 10 "first".
1001        assert_eq!(messages, vec!["third", "second", "first"]);
1002    }
1003
1004    #[test]
1005    fn severity_counts_tallies_across_uris() {
1006        let l = layer();
1007        l.apply(ev(
1008            "rust",
1009            "file:///a.rs",
1010            None,
1011            vec![
1012                diag(0, DiagnosticSeverity::ERROR, "e1"),
1013                diag(1, DiagnosticSeverity::WARNING, "w1"),
1014            ],
1015        ));
1016        l.apply(ev(
1017            "rust",
1018            "file:///b.rs",
1019            None,
1020            vec![
1021                diag(0, DiagnosticSeverity::ERROR, "e2"),
1022                diag(1, DiagnosticSeverity::HINT, "h1"),
1023                diag(2, DiagnosticSeverity::INFORMATION, "i1"),
1024            ],
1025        ));
1026        let c = l.severity_counts();
1027        assert_eq!(c.errors, 2);
1028        assert_eq!(c.warnings, 1);
1029        assert_eq!(c.info, 1);
1030        assert_eq!(c.hints, 1);
1031        assert_eq!(c.total(), 5);
1032    }
1033
1034    #[test]
1035    fn count_returns_total_across_servers_and_uris() {
1036        let l = layer();
1037        l.apply(ev(
1038            "rust",
1039            "file:///a.rs",
1040            None,
1041            vec![diag(0, DiagnosticSeverity::ERROR, "x")],
1042        ));
1043        l.apply(ev(
1044            "clippy",
1045            "file:///a.rs",
1046            None,
1047            vec![diag(1, DiagnosticSeverity::WARNING, "y")],
1048        ));
1049        l.apply(ev(
1050            "rust",
1051            "file:///b.rs",
1052            None,
1053            vec![diag(0, DiagnosticSeverity::ERROR, "z")],
1054        ));
1055        assert_eq!(l.count(), 3);
1056    }
1057
1058    #[test]
1059    fn clear_drops_everything() {
1060        let l = layer();
1061        l.apply(ev(
1062            "rust",
1063            "file:///x.rs",
1064            None,
1065            vec![diag(0, DiagnosticSeverity::ERROR, "x")],
1066        ));
1067        l.clear();
1068        assert_eq!(l.count(), 0);
1069    }
1070
1071    #[test]
1072    fn clear_uri_drops_only_that_uri() {
1073        let l = layer();
1074        l.apply(ev(
1075            "rust",
1076            "file:///a.rs",
1077            None,
1078            vec![diag(0, DiagnosticSeverity::ERROR, "a")],
1079        ));
1080        l.apply(ev(
1081            "rust",
1082            "file:///b.rs",
1083            None,
1084            vec![diag(0, DiagnosticSeverity::ERROR, "b")],
1085        ));
1086        let a = Uri::from_str("file:///a.rs").unwrap();
1087        l.clear_uri(&a);
1088        assert!(l.diagnostics_for(&a).is_empty());
1089        let b = Uri::from_str("file:///b.rs").unwrap();
1090        assert_eq!(l.diagnostics_for(&b).len(), 1);
1091    }
1092
1093    #[test]
1094    fn clear_server_drops_only_that_servers_entries() {
1095        let l = layer();
1096        l.apply(ev(
1097            "rust",
1098            "file:///x.rs",
1099            None,
1100            vec![diag(0, DiagnosticSeverity::ERROR, "rust")],
1101        ));
1102        l.apply(ev(
1103            "clippy",
1104            "file:///x.rs",
1105            None,
1106            vec![diag(0, DiagnosticSeverity::WARNING, "clippy")],
1107        ));
1108        let id: Arc<str> = Arc::from("rust");
1109        l.clear_server(&id);
1110        let uri = Uri::from_str("file:///x.rs").unwrap();
1111        let d = l.diagnostics_for(&uri);
1112        assert_eq!(d.len(), 1);
1113        assert_eq!(d[0].message, "clippy");
1114    }
1115
1116    #[test]
1117    fn clone_shares_state() {
1118        let l = layer();
1119        let l2 = l.clone();
1120        l.apply(ev(
1121            "rust",
1122            "file:///x.rs",
1123            None,
1124            vec![diag(0, DiagnosticSeverity::ERROR, "x")],
1125        ));
1126        assert_eq!(l2.count(), 1);
1127    }
1128}