Skip to main content

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}