Skip to main content

quarto_error_reporting/
coalesce.rs

1//! Cross-source diagnostic coalescing.
2//!
3//! When a single underlying problem produces a diagnostic on many
4//! pages — for example, one bad `theme:` key in `_quarto.yml`
5//! triggering [`Q-14-1`](../../error_catalog.json) once per rendered
6//! page — the renderer should collapse them into a single emission
7//! that lists the affected pages, rather than printing the same
8//! ariadne block hundreds of times.
9//!
10//! # The primary key is the source location
11//!
12//! Two diagnostics whose `location` resolves to the same source
13//! span in the same file are presumed to be the same error and are
14//! grouped together. We deliberately do **not** include the code or
15//! title in the grouping key — the source location alone is the
16//! relation's primary key (decision recorded in
17//! `claude-notes/plans/2026-05-22-theme-diagnostic-epic.md`).
18//!
19//! If two unrelated checks ever land at the same span this is a
20//! design risk; the v1 cost (one merged emission with a possibly
21//! mixed-content representative) is low. We will widen the key to
22//! `(location, code)` if it turns out to bite.
23//!
24//! # File identity is the resolved path, not the raw `FileId`
25//!
26//! A raw `FileId` is only globally meaningful when it is hash-based
27//! (path-derived, e.g. `quarto_yaml::file_id_for_filename`).
28//! Sequential per-context ids are not: every document's primary file
29//! is `FileId(0)` in its own [`SourceContext`], so keying on the raw
30//! id would falsely merge diagnostics from different files that
31//! happen to sit at identical byte offsets.
32//!
33//! Each input entry carries its own `Option<SourceContext>`, so the
34//! group key resolves the file component through it: if the
35//! location's `FileId` is registered in the entry's context, the key
36//! is the registered file **path**; otherwise it falls back to the
37//! raw id (hash-based ids that aren't registered in per-document
38//! contexts stay stable and collision-safe). The two key flavors
39//! never compare equal to each other.
40//!
41//! Both fallback edges fail toward *splitting* groups, never toward
42//! false merges:
43//!
44//! - paths are compared verbatim (no canonicalization), so two
45//!   contexts registering the same file under different spellings
46//!   (`./_quarto.yml` vs `_quarto.yml`) form two groups;
47//! - the same id resolving in one entry's context but not another's
48//!   (e.g. one entry has no context at all) forms two groups.
49//!
50//! # What does not coalesce
51//!
52//! Diagnostics whose `location` is one of:
53//!
54//! - `None`,
55//! - [`SourceInfo::Concat`], or
56//! - [`SourceInfo::FilterProvenance`],
57//!
58//! pass through as singleton groups (one entry each). These shapes
59//! don't reduce to a single contiguous byte range, so we can't form
60//! a stable group key for them. This is the same conservative
61//! contract as [`SourceInfo::resolve_byte_range`].
62//!
63//! [`SourceInfo::Concat`]: quarto_source_map::SourceInfo::Concat
64//! [`SourceInfo::FilterProvenance`]: quarto_source_map::SourceInfo::FilterProvenance
65//! [`SourceInfo::resolve_byte_range`]: quarto_source_map::SourceInfo::resolve_byte_range
66
67use std::collections::HashMap;
68use std::path::PathBuf;
69
70use quarto_source_map::{FileId, SourceContext, SourceInfo};
71
72use crate::diagnostic::{DiagnosticMessage, TextRenderOptions};
73
74/// One entry from a coalesced render summary.
75///
76/// `affected_files` is in encounter order — the order in which the
77/// caller's iterator produced each (path, diagnostic) pair that
78/// contributed to this group. Singleton groups (size 1) carry one
79/// path; rendered output for them omits the "Affected files:" tail
80/// to match the legacy per-page render.
81#[derive(Debug, Clone)]
82pub struct CoalescedDiagnostic {
83    pub representative: DiagnosticMessage,
84    pub source_context: Option<SourceContext>,
85    pub affected_files: Vec<PathBuf>,
86}
87
88/// Maximum number of file names rendered inline in the "Affected
89/// files:" tail before switching to "… (and N others)".
90///
91/// Tunable; v1 sets it small so the typical "hundreds of pages"
92/// case stays one line.
93pub const AFFECTED_FILES_CAP: usize = 3;
94
95impl CoalescedDiagnostic {
96    /// Render the underlying ariadne diagnostic, followed by an
97    /// `Affected files:` tail listing up to [`AFFECTED_FILES_CAP`]
98    /// of the affected paths and a `(and N others)` count for the
99    /// rest. Single-element groups omit the tail.
100    pub fn to_text(&self) -> String {
101        self.to_text_with_options(&TextRenderOptions::default())
102    }
103
104    /// Like [`Self::to_text`] but with explicit render options
105    /// (mostly useful in tests, where hyperlinks are disabled for
106    /// path-independent assertions).
107    pub fn to_text_with_options(&self, opts: &TextRenderOptions) -> String {
108        let body = self
109            .representative
110            .to_text_with_options(self.source_context.as_ref(), opts);
111        if self.affected_files.len() <= 1 {
112            return body;
113        }
114        let tail = render_affected_files_tail(&self.affected_files);
115        format!("{}\n{}", body, tail)
116    }
117}
118
119fn render_affected_files_tail(paths: &[PathBuf]) -> String {
120    let shown = paths
121        .iter()
122        .take(AFFECTED_FILES_CAP)
123        .map(|p| p.display().to_string())
124        .collect::<Vec<_>>()
125        .join(", ");
126    let remaining = paths.len().saturating_sub(AFFECTED_FILES_CAP);
127    if remaining == 0 {
128        format!("Affected files: {}", shown)
129    } else {
130        format!(
131            "Affected files: {} (and {} other{})",
132            shown,
133            remaining,
134            if remaining == 1 { "" } else { "s" },
135        )
136    }
137}
138
139/// File component of a [`LocationKey`].
140///
141/// The two variants never compare equal to each other, so an entry
142/// whose id resolves through its context can never collide with one
143/// whose id doesn't.
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145enum FileKey {
146    /// The location's `FileId` resolved to a file registered in the
147    /// entry's own `SourceContext`; identity is the registered path.
148    Path(String),
149    /// Unresolvable id — the entry has no context, or the id isn't
150    /// registered in it. Raw ids are only collision-safe when they
151    /// are hash-based (see module docs).
152    Raw(usize),
153}
154
155/// Canonical, hashable form of a [`SourceInfo`] for grouping.
156///
157/// Resolves to the root `Original`'s byte range, with the file
158/// component resolved through the entry's own `SourceContext` (see
159/// [`FileKey`]). Returns `None` for shapes that don't reduce cleanly
160/// (mirrors [`SourceInfo::resolve_byte_range`]).
161#[derive(Debug, Clone, PartialEq, Eq, Hash)]
162struct LocationKey {
163    file: FileKey,
164    start: usize,
165    end: usize,
166}
167
168impl LocationKey {
169    fn from(info: &SourceInfo, ctx: Option<&SourceContext>) -> Option<Self> {
170        let (file_id, start, end) = info.resolve_byte_range()?;
171        let file = match ctx.and_then(|c| c.get_file(FileId(file_id))) {
172            Some(f) => FileKey::Path(f.path.clone()),
173            None => FileKey::Raw(file_id),
174        };
175        Some(LocationKey { file, start, end })
176    }
177}
178
179/// Group the input by source location and return one
180/// [`CoalescedDiagnostic`] per group, in encounter order.
181///
182/// Inputs without a coalescable location (no `location`, or `Concat`
183/// / `FilterProvenance`) pass through as singleton groups in their
184/// original order — they always print exactly once.
185///
186/// The first `(path, diagnostic, source_context)` triple to introduce
187/// a given key becomes the group's representative. Later triples
188/// only contribute to `affected_files`. This matches the principle
189/// that the user sees the first diagnostic they would have seen
190/// before, with extra context appended.
191pub fn coalesce_by_source<I>(input: I) -> Vec<CoalescedDiagnostic>
192where
193    I: IntoIterator<Item = (PathBuf, DiagnosticMessage, Option<SourceContext>)>,
194{
195    let mut groups: Vec<CoalescedDiagnostic> = Vec::new();
196    let mut index: HashMap<LocationKey, usize> = HashMap::new();
197
198    for (path, diagnostic, source_context) in input {
199        let key = diagnostic
200            .location
201            .as_ref()
202            .and_then(|loc| LocationKey::from(loc, source_context.as_ref()));
203        match key {
204            Some(k) => match index.get(&k).copied() {
205                Some(idx) => {
206                    groups[idx].affected_files.push(path);
207                }
208                None => {
209                    let idx = groups.len();
210                    index.insert(k, idx);
211                    groups.push(CoalescedDiagnostic {
212                        representative: diagnostic,
213                        source_context,
214                        affected_files: vec![path],
215                    });
216                }
217            },
218            None => {
219                // Non-coalescable: emit as a singleton group at the
220                // tail. Do not register in the index, so subsequent
221                // identical-but-uncoalescable entries also emit as
222                // singletons.
223                groups.push(CoalescedDiagnostic {
224                    representative: diagnostic,
225                    source_context,
226                    affected_files: vec![path],
227                });
228            }
229        }
230    }
231
232    groups
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::builder::DiagnosticMessageBuilder;
239    use quarto_source_map::{FileId, SourcePiece};
240    use std::sync::Arc;
241
242    fn original(file_id: usize, start: usize, end: usize) -> SourceInfo {
243        SourceInfo::Original {
244            file_id: FileId(file_id),
245            start_offset: start,
246            end_offset: end,
247        }
248    }
249
250    fn diag_at(loc: SourceInfo, title: &str) -> DiagnosticMessage {
251        DiagnosticMessageBuilder::error(title)
252            .with_code("Q-14-1")
253            .with_location(loc)
254            .problem("…")
255            .build()
256    }
257
258    #[test]
259    fn two_diagnostics_at_the_same_location_collapse() {
260        let loc = original(1, 100, 110);
261        let input = vec![
262            (PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), None),
263            (PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
264        ];
265        let groups = coalesce_by_source(input);
266        assert_eq!(groups.len(), 1);
267        assert_eq!(
268            groups[0].affected_files,
269            vec![PathBuf::from("a.qmd"), PathBuf::from("b.qmd"),]
270        );
271    }
272
273    #[test]
274    fn different_locations_do_not_collapse() {
275        let input = vec![
276            (
277                PathBuf::from("a.qmd"),
278                diag_at(original(1, 100, 110), "T"),
279                None,
280            ),
281            (
282                PathBuf::from("b.qmd"),
283                diag_at(original(1, 200, 210), "T"),
284                None,
285            ),
286        ];
287        let groups = coalesce_by_source(input);
288        assert_eq!(groups.len(), 2);
289    }
290
291    #[test]
292    fn different_file_ids_do_not_collapse() {
293        let input = vec![
294            (
295                PathBuf::from("a.qmd"),
296                diag_at(original(1, 100, 110), "T"),
297                None,
298            ),
299            (
300                PathBuf::from("b.qmd"),
301                diag_at(original(2, 100, 110), "T"),
302                None,
303            ),
304        ];
305        let groups = coalesce_by_source(input);
306        assert_eq!(groups.len(), 2);
307    }
308
309    #[test]
310    fn substring_resolves_to_root_original_and_groups_with_it() {
311        // A Substring whose root Original matches another Original
312        // must coalesce into the same group — the canonical key is
313        // the resolved root.
314        let root = original(1, 100, 200);
315        let sub = SourceInfo::Substring {
316            parent: Arc::new(root.clone()),
317            // Offsets relative to parent's text; resolve_byte_range
318            // composes them: (fid, parent_start + sub_start,
319            // parent_start + sub_end) = (1, 100, 110).
320            start_offset: 0,
321            end_offset: 10,
322        };
323        let input = vec![
324            (PathBuf::from("a.qmd"), diag_at(root.clone(), "T"), None),
325            (PathBuf::from("b.qmd"), diag_at(sub, "T"), None),
326        ];
327        let groups = coalesce_by_source(input);
328        // root resolves to (1, 100, 200); sub resolves to (1, 100,
329        // 110). Different end offsets ⇒ different keys ⇒ separate
330        // groups. This documents the v1 contract: Substring uses
331        // the *composed* offsets, not the parent's offsets.
332        assert_eq!(groups.len(), 2);
333    }
334
335    #[test]
336    fn concat_location_passes_through_as_singleton() {
337        let concat = SourceInfo::Concat {
338            pieces: vec![SourcePiece {
339                source_info: original(1, 0, 10),
340                offset_in_concat: 0,
341                length: 10,
342            }],
343        };
344        let input = vec![
345            (PathBuf::from("a.qmd"), diag_at(concat.clone(), "T"), None),
346            (PathBuf::from("b.qmd"), diag_at(concat, "T"), None),
347        ];
348        let groups = coalesce_by_source(input);
349        // Both emitted as singletons because Concat has no
350        // coalescable key. Order preserved.
351        assert_eq!(groups.len(), 2);
352        assert_eq!(groups[0].affected_files, vec![PathBuf::from("a.qmd")]);
353        assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
354    }
355
356    #[test]
357    fn diagnostics_without_location_pass_through_as_singletons() {
358        let d = DiagnosticMessageBuilder::error("no location")
359            .problem("…")
360            .build();
361        let input = vec![
362            (PathBuf::from("a.qmd"), d.clone(), None),
363            (PathBuf::from("b.qmd"), d, None),
364        ];
365        let groups = coalesce_by_source(input);
366        assert_eq!(groups.len(), 2);
367    }
368
369    #[test]
370    fn encounter_order_preserved_across_groups() {
371        let loc1 = original(1, 100, 110);
372        let loc2 = original(1, 200, 210);
373        let input = vec![
374            (PathBuf::from("a.qmd"), diag_at(loc1.clone(), "T1"), None),
375            (PathBuf::from("b.qmd"), diag_at(loc2.clone(), "T2"), None),
376            (PathBuf::from("c.qmd"), diag_at(loc1.clone(), "T1"), None),
377        ];
378        let groups = coalesce_by_source(input);
379        assert_eq!(groups.len(), 2);
380        // Group order = order of first occurrence.
381        assert_eq!(groups[0].representative.title, "T1");
382        assert_eq!(
383            groups[0].affected_files,
384            vec![PathBuf::from("a.qmd"), PathBuf::from("c.qmd"),]
385        );
386        assert_eq!(groups[1].representative.title, "T2");
387        assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
388    }
389
390    #[test]
391    fn first_encounter_supplies_representative_and_context() {
392        // The representative is the *first* (path, diagnostic) seen
393        // for a given key. Later contributions only add to
394        // `affected_files`. The same goes for the SourceContext.
395        // Both contexts register the same path for FileId(1), so the
396        // entries key identically and merge.
397        let loc = original(1, 100, 110);
398        let mut ctx_first = SourceContext::new();
399        ctx_first.add_file_with_id(FileId(1), "config.yml".into(), Some("first".into()));
400        let mut ctx_second = SourceContext::new();
401        ctx_second.add_file_with_id(FileId(1), "config.yml".into(), Some("second".into()));
402
403        let input = vec![
404            (
405                PathBuf::from("a.qmd"),
406                diag_at(loc.clone(), "first"),
407                Some(ctx_first),
408            ),
409            (
410                PathBuf::from("b.qmd"),
411                diag_at(loc.clone(), "second"),
412                Some(ctx_second),
413            ),
414        ];
415        let groups = coalesce_by_source(input);
416        assert_eq!(groups.len(), 1);
417        assert_eq!(groups[0].representative.title, "first");
418        let kept = groups[0].source_context.as_ref().expect("context kept");
419        assert_eq!(
420            kept.get_file(FileId(1)).unwrap().content.as_deref(),
421            Some("first"),
422            "the group must keep the first entry's SourceContext"
423        );
424    }
425
426    #[test]
427    fn hash_based_id_with_same_path_collapses_across_contexts() {
428        // Hash-based ids are registered per-document, but every
429        // document's context maps them to the same path — entries
430        // must merge into one group in encounter order.
431        let hash_id = 0xdeadbeef_usize;
432        let loc = original(hash_id, 40, 50);
433        let names = ["a", "b", "c"];
434        let input: Vec<_> = names
435            .iter()
436            .map(|n| {
437                let mut ctx = SourceContext::new();
438                ctx.add_file_with_id(
439                    FileId(hash_id),
440                    "_quarto.yml".into(),
441                    Some("theme: nope".into()),
442                );
443                (
444                    PathBuf::from(format!("{n}.qmd")),
445                    diag_at(loc.clone(), "T"),
446                    Some(ctx),
447                )
448            })
449            .collect();
450        let groups = coalesce_by_source(input);
451        assert_eq!(groups.len(), 1);
452        assert_eq!(
453            groups[0].affected_files,
454            vec![
455                PathBuf::from("a.qmd"),
456                PathBuf::from("b.qmd"),
457                PathBuf::from("c.qmd"),
458            ]
459        );
460    }
461
462    #[test]
463    fn sequential_id_collision_across_contexts_does_not_collapse() {
464        // Regression test for GH #3: every document's primary file is
465        // FileId(0) in its own SourceContext. Identical byte offsets
466        // in *different* files must not merge.
467        let loc = original(0, 10, 20);
468        let mut ctx_a = SourceContext::new();
469        assert_eq!(
470            ctx_a.add_file("a.qmd".into(), Some("contents a".into())),
471            FileId(0)
472        );
473        let mut ctx_b = SourceContext::new();
474        assert_eq!(
475            ctx_b.add_file("b.qmd".into(), Some("contents b".into())),
476            FileId(0)
477        );
478
479        let input = vec![
480            (
481                PathBuf::from("a.qmd"),
482                diag_at(loc.clone(), "in a"),
483                Some(ctx_a),
484            ),
485            (
486                PathBuf::from("b.qmd"),
487                diag_at(loc.clone(), "in b"),
488                Some(ctx_b),
489            ),
490        ];
491        let groups = coalesce_by_source(input);
492        assert_eq!(groups.len(), 2, "FileId(0) in two contexts is two files");
493        assert_eq!(groups[0].representative.title, "in a");
494        assert_eq!(groups[0].affected_files, vec![PathBuf::from("a.qmd")]);
495        assert_eq!(groups[1].representative.title, "in b");
496        assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
497    }
498
499    #[test]
500    fn resolvable_and_unresolvable_same_raw_id_do_not_collapse() {
501        // Accepted split-not-merge edge (module docs): the same raw
502        // id keys as Path in an entry whose context registers it and
503        // as Raw in an entry without a context. Path and Raw keys
504        // never compare equal, so the entries split.
505        let loc = original(7, 10, 20);
506        let mut ctx = SourceContext::new();
507        ctx.add_file_with_id(FileId(7), "seven.yml".into(), Some("s".into()));
508
509        let input = vec![
510            (PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), Some(ctx)),
511            (PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
512        ];
513        let groups = coalesce_by_source(input);
514        assert_eq!(groups.len(), 2);
515    }
516
517    #[test]
518    fn singleton_group_omits_affected_files_tail() {
519        let loc = original(1, 100, 110);
520        let input = vec![(PathBuf::from("a.qmd"), diag_at(loc, "T"), None)];
521        let groups = coalesce_by_source(input);
522        let opts = TextRenderOptions {
523            enable_hyperlinks: false,
524        };
525        let text = groups[0].to_text_with_options(&opts);
526        assert!(
527            !text.contains("Affected files:"),
528            "singleton groups must not emit the affected-files tail:\n{}",
529            text
530        );
531    }
532
533    #[test]
534    fn multi_group_below_cap_lists_all_files() {
535        let loc = original(1, 100, 110);
536        let input = vec![
537            (PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), None),
538            (PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
539        ];
540        let groups = coalesce_by_source(input);
541        let opts = TextRenderOptions {
542            enable_hyperlinks: false,
543        };
544        let text = groups[0].to_text_with_options(&opts);
545        assert!(text.contains("Affected files: a.qmd, b.qmd"), "{}", text);
546        assert!(
547            !text.contains("other"),
548            "no '(and N others)' tail expected for ≤ cap:\n{}",
549            text
550        );
551    }
552
553    #[test]
554    fn multi_group_above_cap_truncates_and_counts() {
555        // AFFECTED_FILES_CAP=3, so 5 files should produce
556        // "a.qmd, b.qmd, c.qmd (and 2 others)".
557        let loc = original(1, 100, 110);
558        let input: Vec<_> = ["a", "b", "c", "d", "e"]
559            .iter()
560            .map(|n| {
561                (
562                    PathBuf::from(format!("{n}.qmd")),
563                    diag_at(loc.clone(), "T"),
564                    None,
565                )
566            })
567            .collect();
568        let groups = coalesce_by_source(input);
569        let opts = TextRenderOptions {
570            enable_hyperlinks: false,
571        };
572        let text = groups[0].to_text_with_options(&opts);
573        assert!(
574            text.contains("Affected files: a.qmd, b.qmd, c.qmd (and 2 others)"),
575            "{}",
576            text,
577        );
578    }
579
580    #[test]
581    fn multi_group_just_above_cap_uses_singular_other() {
582        // 4 files at cap=3 ⇒ 1 other (singular).
583        let loc = original(1, 100, 110);
584        let input: Vec<_> = ["a", "b", "c", "d"]
585            .iter()
586            .map(|n| {
587                (
588                    PathBuf::from(format!("{n}.qmd")),
589                    diag_at(loc.clone(), "T"),
590                    None,
591                )
592            })
593            .collect();
594        let groups = coalesce_by_source(input);
595        let opts = TextRenderOptions {
596            enable_hyperlinks: false,
597        };
598        let text = groups[0].to_text_with_options(&opts);
599        assert!(
600            text.contains("(and 1 other)"),
601            "expected singular 'other' for exactly 1 over cap:\n{}",
602            text,
603        );
604    }
605}