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}