Skip to main content

lattice_lsp/
error_list_feed.rs

1//! EP.3 (2026-08-10): the language server as a **second producer** of
2//! the core error list.
3//!
4//! Design: `docs/dev/architecture/error-list.md` §3.2–§3.3. Slice plan:
5//! `docs/dev/operations/slice-plans/error-list-producers.md`.
6//!
7//! `*problems*`, the `:error-list` picker and the whole `:next-error`
8//! family already exist and are producer-agnostic. Feeding them from
9//! diagnostics is what turns "the errors from my last compile" into
10//! "everything currently wrong", without a second surface to learn.
11//!
12//! ## Coalescing is not an optimisation
13//!
14//! `publishDiagnostics` arrives per-URI at edit-debounce rate. Pushing
15//! a rebuilt `Vec<ErrorEntry>` per notification would drive allocation
16//! and a cross-thread send on every keystroke burst — the background
17//! churn paramount goal #1 forbids. Instead the feed watches
18//! [`DiagnosticsLayer::snapshot_revision`] and rebuilds at most once
19//! per quiet period.
20//!
21//! ## Scope, stated honestly
22//!
23//! This surfaces what servers have **published**, which is not a
24//! workspace scan. rust-analyzer publishes workspace-wide after a
25//! check; other servers publish only for open files. Callers echo the
26//! entry count so an empty result is not misread as a clean tree.
27
28use std::sync::Arc;
29use std::time::Duration;
30
31use lsp_types::{Diagnostic, DiagnosticSeverity, Uri};
32
33use lattice_protocol::error_list::{ErrorEntry, ErrorSeverity};
34
35use crate::actor::uri_to_path;
36use crate::diagnostics_layer::DiagnosticsLayer;
37
38/// How long the feed waits for quiet before rebuilding.
39///
40/// Long enough that a burst of keystrokes produces one push rather than
41/// one per publish; short enough that the list feels live. Tuned by the
42/// same reasoning as other edit-debounces in the editor, not measured —
43/// if the bench in the slice plan says otherwise, this is the knob.
44pub const COALESCE_INTERVAL: Duration = Duration::from_millis(250);
45
46/// Map LSP's severity onto the error list's.
47///
48/// `ErrorSeverity`'s own doc-comment anticipated this: *"producers map
49/// their own severity onto this small set."* A diagnostic with no
50/// severity is treated as an error — servers omit it rarely, and
51/// under-reporting a real error is worse than over-reporting a hint.
52pub fn map_severity(severity: Option<DiagnosticSeverity>) -> ErrorSeverity {
53    match severity {
54        Some(DiagnosticSeverity::WARNING) => ErrorSeverity::Warning,
55        Some(DiagnosticSeverity::INFORMATION) => ErrorSeverity::Info,
56        Some(DiagnosticSeverity::HINT) => ErrorSeverity::Note,
57        // ERROR, or an unknown/absent severity.
58        _ => ErrorSeverity::Error,
59    }
60}
61
62/// Convert one snapshot of the diagnostics layer into error entries.
63///
64/// URIs that do not resolve to a filesystem path are **skipped, not
65/// failed**: a server may publish against `untitled:` or a custom
66/// scheme, and one unmappable URI must not cost the user every other
67/// entry. Ordering follows the snapshot (URIs sorted, diagnostics by
68/// line then column), which gives a stable list across rebuilds — the
69/// property EP.2's re-anchoring depends on.
70pub fn entries_from_snapshot(snapshot: &[(Uri, Vec<Diagnostic>)]) -> Vec<ErrorEntry> {
71    let mut entries = Vec::new();
72    for (uri, diagnostics) in snapshot {
73        let Some(path) = uri_to_path(uri) else {
74            tracing::debug!(uri = %uri.as_str(), "diagnostics: URI has no file path; skipping");
75            continue;
76        };
77        for d in diagnostics {
78            entries.push(ErrorEntry {
79                path: path.clone(),
80                // LSP positions are already 0-based, which is the
81                // convention `jump_to_file_line_col` expects.
82                line: d.range.start.line,
83                col: d.range.start.character,
84                severity: map_severity(d.severity),
85                message: d.message.clone(),
86            });
87        }
88    }
89    entries
90}
91
92/// Build the current entry set from the layer. The pull half of the
93/// feed — used by `:lsp-diagnostics-to-error-list` and by the
94/// option's `false → true` transition, so a snapshot and a live tick
95/// produce identical results.
96pub fn current_entries(layer: &DiagnosticsLayer) -> Vec<ErrorEntry> {
97    entries_from_snapshot(&layer.snapshot())
98}
99
100/// Drive the live feed: rebuild whenever the layer's revision moves and
101/// the caller says the option is on, at most once per
102/// [`COALESCE_INTERVAL`].
103///
104/// Returns the revision it last published, so the caller can thread it
105/// into the next tick. `None` means nothing was published this tick —
106/// either the revision was unchanged or the feed is disabled.
107///
108/// Split out from any spawning so it is testable without a runtime:
109/// the coalescing rule is the part worth pinning, not tokio's timer.
110pub fn poll_feed(
111    layer: &DiagnosticsLayer,
112    last_published: &mut usize,
113    enabled: bool,
114) -> Option<Vec<ErrorEntry>> {
115    if !enabled {
116        return None;
117    }
118    let revision = layer.snapshot_revision();
119    if revision == *last_published {
120        return None;
121    }
122    *last_published = revision;
123    Some(current_entries(layer))
124}
125
126/// Handle to the running feed. Dropping it stops the task.
127pub struct ErrorListFeed {
128    task: tokio::task::JoinHandle<()>,
129}
130
131impl ErrorListFeed {
132    /// Spawn the coalescing loop.
133    ///
134    /// `emit` receives each rebuilt entry set. In production that is an
135    /// `InboundBus<Vec<ErrorEntry>>::send`, whose `send` bakes in the
136    /// `async_landed` wake — so a republish reaches the screen without
137    /// waiting for a keystroke. A bare `TickCallback` here would
138    /// reproduce the "it only updates when I press something" bug class
139    /// `boot-composition.md` §3 exists to design out.
140    ///
141    /// `enabled` is read every tick rather than captured, so toggling
142    /// `lsp.diagnostics-to-error-list` takes effect without a respawn.
143    pub fn spawn<E, F>(layer: DiagnosticsLayer, enabled: F, emit: E) -> Self
144    where
145        E: Fn(Vec<ErrorEntry>) + Send + 'static,
146        F: Fn() -> bool + Send + 'static,
147    {
148        let task = tokio::spawn(async move {
149            let mut last_published = 0usize;
150            let mut ticker = tokio::time::interval(COALESCE_INTERVAL);
151            // A missed tick means the editor was busy; skipping is
152            // right — we want the latest state, not a backlog of
153            // rebuilds.
154            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
155            loop {
156                ticker.tick().await;
157                if let Some(entries) = poll_feed(&layer, &mut last_published, enabled()) {
158                    emit(entries);
159                }
160            }
161        });
162        Self { task }
163    }
164}
165
166impl Drop for ErrorListFeed {
167    fn drop(&mut self) {
168        self.task.abort();
169    }
170}
171
172/// Shared handle so boot can register the feed and the ex-command can
173/// reach the layer for a manual pull.
174pub type ErrorListFeedHandle = Arc<ErrorListFeed>;
175
176#[cfg(test)]
177mod tests {
178    #![allow(clippy::unwrap_used)]
179    use super::*;
180    use lsp_types::{Position, Range};
181
182    fn diag(line: u32, col: u32, severity: Option<DiagnosticSeverity>, msg: &str) -> Diagnostic {
183        Diagnostic {
184            range: Range {
185                start: Position {
186                    line,
187                    character: col,
188                },
189                end: Position {
190                    line,
191                    character: col + 1,
192                },
193            },
194            severity,
195            message: msg.to_string(),
196            ..Default::default()
197        }
198    }
199
200    fn uri(s: &str) -> Uri {
201        s.parse().unwrap()
202    }
203
204    #[test]
205    fn severity_maps_every_variant() {
206        assert_eq!(
207            map_severity(Some(DiagnosticSeverity::ERROR)),
208            ErrorSeverity::Error
209        );
210        assert_eq!(
211            map_severity(Some(DiagnosticSeverity::WARNING)),
212            ErrorSeverity::Warning
213        );
214        assert_eq!(
215            map_severity(Some(DiagnosticSeverity::INFORMATION)),
216            ErrorSeverity::Info
217        );
218        assert_eq!(
219            map_severity(Some(DiagnosticSeverity::HINT)),
220            ErrorSeverity::Note
221        );
222    }
223
224    /// A server that omits severity must not have its diagnostic
225    /// silently demoted — under-reporting a real error is the worse
226    /// failure.
227    #[test]
228    fn absent_severity_is_treated_as_an_error() {
229        assert_eq!(map_severity(None), ErrorSeverity::Error);
230    }
231
232    #[test]
233    fn entries_carry_zero_based_line_and_column() {
234        let snap = vec![(
235            uri("file:///tmp/a.rs"),
236            vec![diag(4, 9, Some(DiagnosticSeverity::ERROR), "boom")],
237        )];
238        let entries = entries_from_snapshot(&snap);
239        assert_eq!(entries.len(), 1);
240        assert_eq!(entries[0].line, 4);
241        assert_eq!(entries[0].col, 9);
242        assert_eq!(entries[0].message, "boom");
243        assert_eq!(entries[0].severity, ErrorSeverity::Error);
244    }
245
246    /// One unmappable URI must not cost the user every other entry.
247    #[test]
248    fn a_non_file_uri_is_skipped_not_fatal() {
249        let snap = vec![
250            (
251                uri("untitled:Untitled-1"),
252                vec![diag(0, 0, Some(DiagnosticSeverity::ERROR), "scratch")],
253            ),
254            (
255                uri("file:///tmp/real.rs"),
256                vec![diag(1, 0, Some(DiagnosticSeverity::WARNING), "real")],
257            ),
258        ];
259        let entries = entries_from_snapshot(&snap);
260        assert_eq!(entries.len(), 1, "the file:// entry survives");
261        assert_eq!(entries[0].message, "real");
262    }
263
264    #[test]
265    fn an_empty_snapshot_yields_no_entries() {
266        assert!(entries_from_snapshot(&[]).is_empty());
267    }
268
269    /// The coalescing rule: an unchanged revision publishes nothing, so
270    /// a quiet editor does no work at all.
271    #[test]
272    fn poll_publishes_only_when_the_revision_moves() {
273        let layer = DiagnosticsLayer::default();
274        let mut last = layer.snapshot_revision();
275
276        assert!(
277            poll_feed(&layer, &mut last, true).is_none(),
278            "unchanged revision must not republish"
279        );
280    }
281
282    #[test]
283    fn poll_publishes_nothing_while_disabled() {
284        let layer = DiagnosticsLayer::default();
285        // A revision the feed has never seen.
286        let mut last = usize::MAX;
287        assert!(
288            poll_feed(&layer, &mut last, false).is_none(),
289            "the option gates the feed, not just the echo"
290        );
291    }
292}