lattice_host/error_list.rs
1//! CM.2 (2026-07-22): the **error list** — a persistent,
2//! cross-file list of navigable locations with an index, walked by
3//! *generic* host dispatch (`:cnext` / `:cprev` / `:cc` and the
4//! Builtin `]q` / `[q` chords).
5//!
6//! Placement mirrors `position_history` on [`crate::editor::Editor`]:
7//! this is **core/host state**, NOT owned by any mode. By the
8//! substrate-vs-mode-helper rule (uniform-host consumer ⟹ core),
9//! its consumer is generic navigation dispatch, so it lives on the
10//! host like the jump ring. Compilation (CM.3), diagnostics, and
11//! search are all *producers* that populate it via
12//! [`crate::editor::Editor::set_error_list`].
13//!
14//! See `docs/dev/architecture/compilation-mode.md` §3.
15
16// CM.3a (2026-07-22): the entry + severity value types moved down to
17// `lattice-protocol` so the below-host compilation parser and the
18// `AppEffect::SetErrorList` payload share ONE type. Re-exported here so
19// existing callers (`lattice_host::error_list::ErrorEntry`, the CM.2
20// tests) are unchanged. The *list* stays host-local (below).
21pub use lattice_protocol::error_list::{ErrorEntry, ErrorSeverity, ErrorSource};
22
23/// EP.2: what a slice write should do with the navigation index.
24///
25/// Private because the choice is expressed at the call site by picking
26/// [`ErrorList::set`] or [`ErrorList::refresh`] — naming the two
27/// intentions rather than passing a bool nobody can read.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum Anchor {
30 /// New run: start at the top.
31 Reset,
32 /// Live refresh: keep the user where they were.
33 Keep,
34}
35
36/// The error list: an ordered set of entries plus a cursor
37/// (`index`) into them. `:cnext` / `]q` walk the index (wrapping
38/// vim-style); `:cc N` jumps to the Nth (1-based). Empty by default.
39///
40/// EP.1 (2026-08-10): entries are held as **per-source slices**, not
41/// one flat vec, and a producer's write replaces only its own slice.
42/// The flat view every consumer reads ([`Self::entries`]) is the
43/// concatenation in [`ErrorSource::PRESENTATION_ORDER`], each slice
44/// keeping its producer's own ordering.
45///
46/// Two producers on one untagged list is a clobber: the language server
47/// republishes on every edit-debounce, so its feed would wipe a compile
48/// run's entries while the user walked them. Sorting the merged list
49/// instead of concatenating was rejected — producer order is
50/// information (rustc emits the root cause before the cascade).
51///
52/// See `docs/dev/architecture/error-list.md` §3.1.
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct ErrorList {
55 /// One run per source, kept in `PRESENTATION_ORDER`. A source with
56 /// nothing to show simply has no run here.
57 slices: Vec<(ErrorSource, Vec<ErrorEntry>)>,
58 /// Flat concatenation of `slices`, rebuilt on every write. Cached
59 /// rather than recomputed because `entries()` returns a borrowed
60 /// slice and every navigation call reads it.
61 flat: Vec<ErrorEntry>,
62 index: usize,
63}
64
65impl ErrorList {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 /// Replace `source`'s entries for a **new run**, leaving every
71 /// other source's alone.
72 ///
73 /// This is the producer entry point (compilation / LSP call it via
74 /// [`crate::editor::Editor::set_error_list`]). An empty `entries`
75 /// clears just that source's run — which is what a fresh compile
76 /// with no errors means, and it must not disturb the language
77 /// server's diagnostics sitting alongside.
78 ///
79 /// Resets the index to 0, which is what a new run means: the
80 /// user asked for fresh results and expects to start at the top.
81 /// For a *refresh* of an existing feed use [`Self::refresh`] —
82 /// resetting there would throw a user walking entry 7 back to entry
83 /// 1 every time they typed.
84 pub fn set(&mut self, source: ErrorSource, entries: Vec<ErrorEntry>) {
85 self.splice(source, entries, Anchor::Reset);
86 }
87
88 /// EP.2 (2026-08-10): replace `source`'s entries the way a *live
89 /// feed* does — keeping the user where they were.
90 ///
91 /// The distinction from [`Self::set`] is the **producer's to
92 /// declare**, never inferred from the data: a language server
93 /// republishing after a keystroke is a refresh, a compile run is a
94 /// new run, and the two are indistinguishable by looking at the
95 /// entries. Without this, the default-on diagnostic feed would make
96 /// `:cnext` unusable — which is precisely the experience someone
97 /// would set `lsp.diagnostics-to-error-list = false` to escape.
98 ///
99 /// Re-anchoring, in order: the same entry by `(path, message)`
100 /// (tolerant of line drift, since typing above an error moves it);
101 /// else the first entry of the same path at-or-after the old line;
102 /// else index 0.
103 pub fn refresh(&mut self, source: ErrorSource, entries: Vec<ErrorEntry>) {
104 self.splice(source, entries, Anchor::Keep);
105 }
106
107 /// Shared body of [`Self::set`] and [`Self::refresh`].
108 fn splice(&mut self, source: ErrorSource, entries: Vec<ErrorEntry>, anchor: Anchor) {
109 // Capture the identity of the entry under the index BEFORE the
110 // rebuild — afterwards the ordinal is meaningless.
111 let previous = match anchor {
112 Anchor::Keep => self.current().cloned(),
113 Anchor::Reset => None,
114 };
115
116 match self.slices.iter_mut().find(|(s, _)| *s == source) {
117 Some(run) => run.1 = entries,
118 None => self.slices.push((source, entries)),
119 }
120 self.slices.retain(|(_, e)| !e.is_empty());
121 self.slices.sort_by_key(|(s, _)| {
122 ErrorSource::PRESENTATION_ORDER
123 .iter()
124 .position(|p| p == s)
125 .unwrap_or(usize::MAX)
126 });
127 self.rebuild_flat();
128
129 self.index = match previous {
130 None => 0,
131 Some(prev) => self.reanchor(&prev),
132 };
133 }
134
135 /// Where the index should land after a refresh, given the entry it
136 /// pointed at before. See [`Self::refresh`] for the ordering.
137 fn reanchor(&self, prev: &ErrorEntry) -> usize {
138 // 1. The same entry, wherever it moved to. Line is excluded
139 // from the match on purpose: editing above an error shifts
140 // its line without making it a different error.
141 if let Some(i) = self
142 .flat
143 .iter()
144 .position(|e| e.path == prev.path && e.message == prev.message)
145 {
146 return i;
147 }
148 // 2. It is gone — land on the next surviving entry in the same
149 // file, so the user keeps working where they were.
150 if let Some(i) = self
151 .flat
152 .iter()
153 .position(|e| e.path == prev.path && e.line >= prev.line)
154 {
155 return i;
156 }
157 // 3. The file itself is clean now. Start over rather than
158 // pointing somewhere arbitrary.
159 0
160 }
161
162 /// Rebuild the cached flat view from `slices`.
163 fn rebuild_flat(&mut self) {
164 self.flat = self
165 .slices
166 .iter()
167 .flat_map(|(_, entries)| entries.iter().cloned())
168 .collect();
169 }
170
171 /// The entries contributed by one source, or an empty slice when it
172 /// has none.
173 pub fn entries_from(&self, source: ErrorSource) -> &[ErrorEntry] {
174 self.slices
175 .iter()
176 .find(|(s, _)| *s == source)
177 .map(|(_, e)| e.as_slice())
178 .unwrap_or(&[])
179 }
180
181 /// Which sources currently contribute entries, in presentation
182 /// order.
183 pub fn sources(&self) -> Vec<ErrorSource> {
184 self.slices.iter().map(|(s, _)| *s).collect()
185 }
186
187 pub fn is_empty(&self) -> bool {
188 self.flat.is_empty()
189 }
190
191 /// CM.4 (2026-07-22): read-only slice of the entries. The
192 /// `:copen` producer clones these to build the `*problems*`
193 /// multibuffer view.
194 pub fn entries(&self) -> &[ErrorEntry] {
195 &self.flat
196 }
197
198 pub fn len(&self) -> usize {
199 self.flat.len()
200 }
201
202 /// The 0-based index the list currently points at. Meaningless
203 /// (returns 0) when the list is empty.
204 pub fn index(&self) -> usize {
205 self.index
206 }
207
208 /// The entry the index currently points at, or `None` when the
209 /// list is empty.
210 pub fn current(&self) -> Option<&ErrorEntry> {
211 self.flat.get(self.index)
212 }
213
214 /// Move the index by `delta`, wrapping vim-style (`:cnext` past
215 /// the last entry wraps to the first; `:cprev` past the first
216 /// wraps to the last), and return the entry now under the index.
217 /// `None` only when the list is empty.
218 pub fn step(&mut self, delta: i64) -> Option<&ErrorEntry> {
219 let len = self.flat.len();
220 if len == 0 {
221 return None;
222 }
223 // `rem_euclid` keeps the result in `[0, len)` for any sign.
224 let next = (self.index as i64 + delta).rem_euclid(len as i64);
225 self.index = next as usize;
226 self.flat.get(self.index)
227 }
228
229 /// Jump to the `n`th entry (1-based, vim `:cc N`). `n == None`
230 /// (bare `:cc`) keeps the current index and returns the current
231 /// entry. An out-of-range `n` (0, or past the end) leaves the
232 /// index unchanged and returns `None`.
233 pub fn jump_to(&mut self, n: Option<usize>) -> Option<&ErrorEntry> {
234 match n {
235 None => self.current(),
236 Some(n) => {
237 if n == 0 || n > self.flat.len() {
238 return None;
239 }
240 self.index = n - 1;
241 self.flat.get(self.index)
242 }
243 }
244 }
245
246 /// CM.3b: point the index at the first entry whose `(path, line)`
247 /// matches, returning `true` when one was found (index moved) and
248 /// `false` otherwise (index unchanged). Used by the `<CR>`-jump in
249 /// `*compilation*` to sync the error cursor to the entry the
250 /// user jumped to. Match is on `path` + 0-based `line` only —
251 /// column is ignored so a jump to a line with several column-
252 /// distinct diagnostics selects the first at that line.
253 pub fn set_index_to_matching(&mut self, path: &std::path::Path, line: u32) -> bool {
254 if let Some(pos) = self
255 .flat
256 .iter()
257 .position(|e| e.line == line && e.path == path)
258 {
259 self.index = pos;
260 true
261 } else {
262 false
263 }
264 }
265
266 /// CM.7: move to the first entry of the next (`delta > 0`) or
267 /// previous (`delta < 0`) **file**, wrapping vim-style, and return
268 /// the entry now under the index. `:cnextfile` (`delta = 1`) /
269 /// `:cprevfile` (`delta = -1`); a count moves that many files.
270 ///
271 /// A "file" is a maximal run of consecutive entries sharing a
272 /// `path` (compiler / tool output groups a file's locations
273 /// together; the parser preserves that order). Both directions
274 /// land on the **first** entry of the target file — symmetric and
275 /// intuitive (vim's `:cpfile` technically lands on the *last* entry
276 /// of the previous file; we deliberately land on the first so a
277 /// following `:cnext` walks that file top-to-bottom). `None` only
278 /// when the list is empty.
279 pub fn step_file(&mut self, delta: i64) -> Option<&ErrorEntry> {
280 if self.flat.is_empty() {
281 return None;
282 }
283 // Group-start indices: index 0, plus every index whose path
284 // differs from the previous entry's (contiguous same-path run =
285 // one file group).
286 let mut starts: Vec<usize> = vec![0];
287 for i in 1..self.flat.len() {
288 if self.flat[i].path != self.flat[i - 1].path {
289 starts.push(i);
290 }
291 }
292 // The group the index currently sits in = the last start <= index.
293 let cur_group = starts.iter().rposition(|&s| s <= self.index).unwrap_or(0);
294 let ngroups = starts.len() as i64;
295 let next_group = (cur_group as i64 + delta).rem_euclid(ngroups) as usize;
296 self.index = starts[next_group];
297 self.flat.get(self.index)
298 }
299
300 /// Jump to the first entry (`:cfirst`). `None` when empty.
301 pub fn first(&mut self) -> Option<&ErrorEntry> {
302 if self.flat.is_empty() {
303 return None;
304 }
305 self.index = 0;
306 self.flat.first()
307 }
308
309 /// Jump to the last entry (`:clast`). `None` when empty.
310 pub fn last(&mut self) -> Option<&ErrorEntry> {
311 if self.flat.is_empty() {
312 return None;
313 }
314 self.index = self.flat.len() - 1;
315 self.flat.last()
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::path::PathBuf;
323
324 fn entry(path: &str, line: u32) -> ErrorEntry {
325 ErrorEntry {
326 path: PathBuf::from(path),
327 line,
328 col: 0,
329 severity: ErrorSeverity::Error,
330 message: format!("msg {line}"),
331 }
332 }
333
334 #[test]
335 fn empty_list_has_no_current() {
336 let qf = ErrorList::new();
337 assert!(qf.is_empty());
338 assert_eq!(qf.len(), 0);
339 assert_eq!(qf.current(), None);
340 }
341
342 #[test]
343 fn set_resets_index_to_zero() {
344 let mut qf = ErrorList::new();
345 qf.set(
346 ErrorSource::Compilation,
347 vec![entry("a", 1), entry("b", 2), entry("c", 3)],
348 );
349 // Walk forward, then re-set: index must reset.
350 qf.step(2);
351 assert_eq!(qf.index(), 2);
352 qf.set(ErrorSource::Compilation, vec![entry("x", 9), entry("y", 8)]);
353 assert_eq!(qf.index(), 0);
354 assert_eq!(qf.current(), Some(&entry("x", 9)));
355 }
356
357 #[test]
358 fn step_wraps_forward_past_end_to_first() {
359 let mut qf = ErrorList::new();
360 qf.set(ErrorSource::Compilation, vec![entry("a", 1), entry("b", 2)]);
361 assert_eq!(qf.current(), Some(&entry("a", 1)));
362 assert_eq!(qf.step(1), Some(&entry("b", 2)));
363 // Past the end wraps to the first.
364 assert_eq!(qf.step(1), Some(&entry("a", 1)));
365 assert_eq!(qf.index(), 0);
366 }
367
368 #[test]
369 fn step_wraps_backward_past_start_to_last() {
370 let mut qf = ErrorList::new();
371 qf.set(
372 ErrorSource::Compilation,
373 vec![entry("a", 1), entry("b", 2), entry("c", 3)],
374 );
375 // At index 0, stepping back wraps to the last.
376 assert_eq!(qf.step(-1), Some(&entry("c", 3)));
377 assert_eq!(qf.index(), 2);
378 assert_eq!(qf.step(-1), Some(&entry("b", 2)));
379 }
380
381 #[test]
382 fn step_on_empty_is_none() {
383 let mut qf = ErrorList::new();
384 assert_eq!(qf.step(1), None);
385 assert_eq!(qf.step(-1), None);
386 }
387
388 #[test]
389 fn jump_to_is_one_based_and_bounds_checked() {
390 let mut qf = ErrorList::new();
391 qf.set(
392 ErrorSource::Compilation,
393 vec![entry("a", 1), entry("b", 2), entry("c", 3)],
394 );
395 // 1-based: :cc 2 -> index 1.
396 assert_eq!(qf.jump_to(Some(2)), Some(&entry("b", 2)));
397 assert_eq!(qf.index(), 1);
398 // Out of range leaves index unchanged, returns None.
399 assert_eq!(qf.jump_to(Some(0)), None);
400 assert_eq!(qf.jump_to(Some(4)), None);
401 assert_eq!(qf.index(), 1);
402 // Bare :cc keeps the current index.
403 assert_eq!(qf.jump_to(None), Some(&entry("b", 2)));
404 assert_eq!(qf.index(), 1);
405 }
406
407 #[test]
408 fn first_and_last() {
409 let mut qf = ErrorList::new();
410 qf.set(
411 ErrorSource::Compilation,
412 vec![entry("a", 1), entry("b", 2), entry("c", 3)],
413 );
414 assert_eq!(qf.last(), Some(&entry("c", 3)));
415 assert_eq!(qf.index(), 2);
416 assert_eq!(qf.first(), Some(&entry("a", 1)));
417 assert_eq!(qf.index(), 0);
418 }
419
420 #[test]
421 fn set_index_to_matching_finds_by_path_and_line() {
422 let mut qf = ErrorList::new();
423 qf.set(
424 ErrorSource::Compilation,
425 vec![entry("a.rs", 1), entry("b.rs", 5), entry("b.rs", 9)],
426 );
427 // Match on (path, line) moves the index and returns true.
428 assert!(qf.set_index_to_matching(&PathBuf::from("b.rs"), 9));
429 assert_eq!(qf.index(), 2);
430 assert_eq!(qf.current(), Some(&entry("b.rs", 9)));
431 // First match wins when several entries share (path, line)
432 // is not the case here, but a different match re-points.
433 assert!(qf.set_index_to_matching(&PathBuf::from("a.rs"), 1));
434 assert_eq!(qf.index(), 0);
435 // No match leaves the index unchanged and returns false.
436 assert!(!qf.set_index_to_matching(&PathBuf::from("b.rs"), 42));
437 assert_eq!(qf.index(), 0);
438 assert!(!qf.set_index_to_matching(&PathBuf::from("zzz.rs"), 1));
439 assert_eq!(qf.index(), 0);
440 }
441
442 #[test]
443 fn set_index_to_matching_on_empty_is_false() {
444 let mut qf = ErrorList::new();
445 assert!(!qf.set_index_to_matching(&PathBuf::from("a.rs"), 0));
446 }
447
448 #[test]
449 fn first_last_on_empty_is_none() {
450 let mut qf = ErrorList::new();
451 assert_eq!(qf.first(), None);
452 assert_eq!(qf.last(), None);
453 }
454
455 #[test]
456 fn step_file_lands_on_first_entry_of_each_file_and_wraps() {
457 let mut qf = ErrorList::new();
458 // Two files: a.rs (2 entries) then b.rs (2 entries).
459 qf.set(
460 ErrorSource::Compilation,
461 vec![
462 entry("a.rs", 1),
463 entry("a.rs", 4),
464 entry("b.rs", 2),
465 entry("b.rs", 7),
466 ],
467 );
468 // Start at index 0 (a.rs, first). Next file → b.rs first entry.
469 assert_eq!(qf.step_file(1), Some(&entry("b.rs", 2)));
470 assert_eq!(qf.index(), 2);
471 // Next file again wraps back to a.rs's first entry.
472 assert_eq!(qf.step_file(1), Some(&entry("a.rs", 1)));
473 assert_eq!(qf.index(), 0);
474 // Prev file wraps to b.rs's FIRST entry (not last — symmetric).
475 assert_eq!(qf.step_file(-1), Some(&entry("b.rs", 2)));
476 assert_eq!(qf.index(), 2);
477 }
478
479 #[test]
480 fn step_file_from_mid_file_goes_to_next_file_start() {
481 let mut qf = ErrorList::new();
482 qf.set(
483 ErrorSource::Compilation,
484 vec![entry("a.rs", 1), entry("a.rs", 4), entry("b.rs", 2)],
485 );
486 // Sit on a.rs's SECOND entry, then :cnextfile → b.rs first.
487 qf.step(1);
488 assert_eq!(qf.index(), 1);
489 assert_eq!(qf.step_file(1), Some(&entry("b.rs", 2)));
490 assert_eq!(qf.index(), 2);
491 }
492
493 #[test]
494 fn step_file_single_file_is_stable() {
495 let mut qf = ErrorList::new();
496 qf.set(
497 ErrorSource::Compilation,
498 vec![entry("only.rs", 1), entry("only.rs", 9)],
499 );
500 // One file group → next/prev file both resolve to its start.
501 assert_eq!(qf.step_file(1), Some(&entry("only.rs", 1)));
502 assert_eq!(qf.index(), 0);
503 assert_eq!(qf.step_file(-1), Some(&entry("only.rs", 1)));
504 assert_eq!(qf.index(), 0);
505 }
506
507 // ── EP.1: tagged sources, scoped replace ──────────────────────
508 //
509 // The reason the slice exists: two producers on one untagged list
510 // clobber each other. The language server republishes on every
511 // edit-debounce, so its feed would wipe a compile run's entries
512 // while the user was walking them.
513
514 #[test]
515 fn a_write_replaces_only_its_own_source() {
516 let mut qf = ErrorList::new();
517 qf.set(
518 ErrorSource::Compilation,
519 vec![entry("c.rs", 1), entry("c.rs", 2)],
520 );
521 qf.set(ErrorSource::Lsp, vec![entry("l.rs", 9)]);
522 assert_eq!(qf.len(), 3);
523
524 // A second compile run replaces ONLY the compile slice.
525 qf.set(ErrorSource::Compilation, vec![entry("c.rs", 7)]);
526 assert_eq!(qf.entries_from(ErrorSource::Compilation).len(), 1);
527 assert_eq!(
528 qf.entries_from(ErrorSource::Lsp),
529 &[entry("l.rs", 9)],
530 "the LSP slice must survive a compile run — this is the clobber"
531 );
532 assert_eq!(qf.len(), 2);
533 }
534
535 #[test]
536 fn slices_concatenate_in_presentation_order() {
537 let mut qf = ErrorList::new();
538 // Insert LSP first to prove order comes from PRESENTATION_ORDER,
539 // not from insertion.
540 qf.set(ErrorSource::Lsp, vec![entry("l.rs", 9)]);
541 qf.set(ErrorSource::Compilation, vec![entry("c.rs", 1)]);
542 assert_eq!(
543 qf.entries(),
544 &[entry("c.rs", 1), entry("l.rs", 9)],
545 "compilation precedes lsp regardless of write order"
546 );
547 assert_eq!(
548 qf.sources(),
549 vec![ErrorSource::Compilation, ErrorSource::Lsp]
550 );
551 }
552
553 /// Producer order within a slice is preserved — rustc emits the
554 /// root cause ahead of the errors it cascades into, and sorting the
555 /// merged list would destroy that.
556 #[test]
557 fn producer_order_within_a_slice_is_untouched() {
558 let mut qf = ErrorList::new();
559 qf.set(
560 ErrorSource::Compilation,
561 vec![entry("z.rs", 90), entry("a.rs", 1), entry("m.rs", 50)],
562 );
563 assert_eq!(
564 qf.entries(),
565 &[entry("z.rs", 90), entry("a.rs", 1), entry("m.rs", 50)],
566 "entries must NOT be sorted by path or line"
567 );
568 }
569
570 #[test]
571 fn an_empty_write_clears_only_that_source() {
572 let mut qf = ErrorList::new();
573 qf.set(ErrorSource::Compilation, vec![entry("c.rs", 1)]);
574 qf.set(ErrorSource::Lsp, vec![entry("l.rs", 9)]);
575
576 // A clean build sends an empty vec.
577 qf.set(ErrorSource::Compilation, vec![]);
578 assert!(qf.entries_from(ErrorSource::Compilation).is_empty());
579 assert_eq!(qf.entries(), &[entry("l.rs", 9)]);
580 assert_eq!(qf.sources(), vec![ErrorSource::Lsp]);
581 assert!(!qf.is_empty(), "the LSP slice still has entries");
582 }
583
584 /// `step_file`'s "maximal run of consecutive entries sharing a
585 /// path" operates on the concatenation, so it still lands on
586 /// first-of-file across a two-slice list.
587 #[test]
588 fn step_file_works_across_two_slices() {
589 let mut qf = ErrorList::new();
590 qf.set(
591 ErrorSource::Compilation,
592 vec![entry("c.rs", 1), entry("c.rs", 4)],
593 );
594 qf.set(ErrorSource::Lsp, vec![entry("l.rs", 2), entry("l.rs", 7)]);
595
596 // Start on c.rs's first. Next file → l.rs's FIRST entry.
597 assert_eq!(qf.step_file(1), Some(&entry("l.rs", 2)));
598 assert_eq!(qf.index(), 2);
599 // Wraps back to c.rs's first.
600 assert_eq!(qf.step_file(1), Some(&entry("c.rs", 1)));
601 assert_eq!(qf.index(), 0);
602 }
603
604 /// A file flagged by BOTH producers stays ONE file group when its
605 /// entries land adjacent across the slice boundary — `step_file`
606 /// groups by *maximal run of consecutive entries sharing a path*,
607 /// and the concatenation puts them side by side. So the common case
608 /// does NOT double-visit.
609 #[test]
610 fn the_same_file_from_two_sources_stays_one_group_when_adjacent() {
611 let mut qf = ErrorList::new();
612 qf.set(ErrorSource::Compilation, vec![entry("same.rs", 1)]);
613 qf.set(ErrorSource::Lsp, vec![entry("same.rs", 5)]);
614 assert_eq!(qf.len(), 2);
615 // One group → `:cnextfile` wraps to its own start.
616 assert_eq!(qf.step_file(1), Some(&entry("same.rs", 1)));
617 assert_eq!(qf.index(), 0);
618 }
619
620 /// The double-visit the concatenation *can* produce, and its actual
621 /// precondition: the shared path is NON-contiguous in the flat view,
622 /// so it forms two separate groups. This is the real cost of
623 /// concatenating rather than merge-sorting — narrower than "any file
624 /// flagged by both producers", which is what the design first said.
625 #[test]
626 fn a_non_contiguous_path_forms_two_groups() {
627 let mut qf = ErrorList::new();
628 // `same.rs` is split by `other.rs` inside the compile slice.
629 qf.set(
630 ErrorSource::Compilation,
631 vec![entry("same.rs", 1), entry("other.rs", 2)],
632 );
633 qf.set(ErrorSource::Lsp, vec![entry("same.rs", 5)]);
634
635 // Groups: [same.rs] [other.rs] [same.rs] — three, not two.
636 assert_eq!(qf.step_file(1), Some(&entry("other.rs", 2)));
637 assert_eq!(qf.step_file(1), Some(&entry("same.rs", 5)));
638 assert_eq!(qf.index(), 2);
639 // And wraps back to the first group.
640 assert_eq!(qf.step_file(1), Some(&entry("same.rs", 1)));
641 }
642
643 // ── EP.2: index re-anchoring across a refresh ─────────────────
644 //
645 // A live diagnostic feed republishes on every edit-debounce. If a
646 // refresh reset the index, walking the list while typing would snap
647 // the user back to entry 1 on every keystroke — the experience
648 // `lsp.diagnostics-to-error-list = false` exists to escape.
649
650 #[test]
651 fn refresh_keeps_the_user_on_the_same_entry_when_one_is_inserted_above() {
652 let mut qf = ErrorList::new();
653 qf.set(
654 ErrorSource::Lsp,
655 vec![entry("a.rs", 10), entry("a.rs", 20), entry("a.rs", 30)],
656 );
657 qf.step(2);
658 assert_eq!(qf.current(), Some(&entry("a.rs", 30)));
659
660 // The server republishes with an extra entry ABOVE — every
661 // ordinal shifts by one.
662 qf.refresh(
663 ErrorSource::Lsp,
664 vec![
665 entry("a.rs", 5),
666 entry("a.rs", 10),
667 entry("a.rs", 20),
668 entry("a.rs", 30),
669 ],
670 );
671 assert_eq!(
672 qf.current(),
673 Some(&entry("a.rs", 30)),
674 "must follow the ENTRY, not the ordinal"
675 );
676 assert_eq!(qf.index(), 3);
677 }
678
679 /// Editing above an error shifts its line without making it a
680 /// different error, so the identity match ignores `line`.
681 #[test]
682 fn refresh_tolerates_line_drift() {
683 let mut qf = ErrorList::new();
684 qf.set(ErrorSource::Lsp, vec![entry("a.rs", 10), entry("a.rs", 20)]);
685 qf.step(1);
686 assert_eq!(qf.current(), Some(&entry("a.rs", 20)));
687
688 // Same two errors, both pushed down three lines.
689 qf.refresh(ErrorSource::Lsp, vec![entry("a.rs", 13), entry("a.rs", 23)]);
690 assert_eq!(qf.index(), 1, "still on the second error, now at line 23");
691 assert_eq!(qf.current().map(|e| e.line), Some(23));
692 }
693
694 /// When the entry is fixed, land on the next surviving one in the
695 /// same file rather than jumping to the top.
696 #[test]
697 fn refresh_falls_forward_within_the_file_when_the_entry_is_gone() {
698 let mut qf = ErrorList::new();
699 qf.set(
700 ErrorSource::Lsp,
701 vec![entry("a.rs", 10), entry("a.rs", 20), entry("a.rs", 30)],
702 );
703 qf.step(1);
704 assert_eq!(qf.current(), Some(&entry("a.rs", 20)));
705
706 // The user fixed the line-20 error.
707 qf.refresh(ErrorSource::Lsp, vec![entry("a.rs", 10), entry("a.rs", 30)]);
708 assert_eq!(
709 qf.current(),
710 Some(&entry("a.rs", 30)),
711 "next surviving entry in the same file, not entry 1"
712 );
713 }
714
715 #[test]
716 fn refresh_resets_when_the_whole_file_is_clean() {
717 let mut qf = ErrorList::new();
718 qf.set(ErrorSource::Lsp, vec![entry("a.rs", 10), entry("b.rs", 1)]);
719 qf.step(1);
720 assert_eq!(qf.current(), Some(&entry("b.rs", 1)));
721
722 qf.refresh(ErrorSource::Lsp, vec![entry("a.rs", 10)]);
723 assert_eq!(qf.index(), 0, "nothing to anchor to — start over");
724 }
725
726 /// The producer declares the intent; a new run still resets even
727 /// when an anchor was available.
728 #[test]
729 fn a_new_run_still_resets_the_index() {
730 let mut qf = ErrorList::new();
731 qf.set(
732 ErrorSource::Compilation,
733 vec![entry("a.rs", 10), entry("a.rs", 20)],
734 );
735 qf.step(1);
736 assert_eq!(qf.index(), 1);
737
738 // Identical entries, but via `set` — a fresh compile.
739 qf.set(
740 ErrorSource::Compilation,
741 vec![entry("a.rs", 10), entry("a.rs", 20)],
742 );
743 assert_eq!(
744 qf.index(),
745 0,
746 "a new run starts at the top even though the entry survived"
747 );
748 }
749
750 /// Re-anchoring reads the FLAT view, so an LSP refresh must not
751 /// move the index off a compile entry the user is sitting on.
752 #[test]
753 fn refresh_of_one_source_keeps_the_index_on_another_sources_entry() {
754 let mut qf = ErrorList::new();
755 qf.set(ErrorSource::Compilation, vec![entry("c.rs", 1)]);
756 qf.set(ErrorSource::Lsp, vec![entry("l.rs", 9)]);
757 // Sit on the compile entry (index 0 after the LSP set).
758 assert_eq!(qf.current(), Some(&entry("c.rs", 1)));
759
760 qf.refresh(ErrorSource::Lsp, vec![entry("l.rs", 9), entry("l.rs", 12)]);
761 assert_eq!(
762 qf.current(),
763 Some(&entry("c.rs", 1)),
764 "an LSP republish must not drag the cursor off a compile entry"
765 );
766 }
767
768 #[test]
769 fn step_file_on_empty_is_none() {
770 let mut qf = ErrorList::new();
771 assert_eq!(qf.step_file(1), None);
772 assert_eq!(qf.step_file(-1), None);
773 }
774}