use std::collections::HashMap;
use std::path::PathBuf;
use quarto_source_map::{SourceContext, SourceInfo};
use crate::diagnostic::{DiagnosticMessage, TextRenderOptions};
#[derive(Debug, Clone)]
pub struct CoalescedDiagnostic {
pub representative: DiagnosticMessage,
pub source_context: Option<SourceContext>,
pub affected_files: Vec<PathBuf>,
}
pub const AFFECTED_FILES_CAP: usize = 3;
impl CoalescedDiagnostic {
pub fn to_text(&self) -> String {
self.to_text_with_options(&TextRenderOptions::default())
}
pub fn to_text_with_options(&self, opts: &TextRenderOptions) -> String {
let body = self
.representative
.to_text_with_options(self.source_context.as_ref(), opts);
if self.affected_files.len() <= 1 {
return body;
}
let tail = render_affected_files_tail(&self.affected_files);
format!("{}\n{}", body, tail)
}
}
fn render_affected_files_tail(paths: &[PathBuf]) -> String {
let shown = paths
.iter()
.take(AFFECTED_FILES_CAP)
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
let remaining = paths.len().saturating_sub(AFFECTED_FILES_CAP);
if remaining == 0 {
format!("Affected files: {}", shown)
} else {
format!(
"Affected files: {} (and {} other{})",
shown,
remaining,
if remaining == 1 { "" } else { "s" },
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct LocationKey {
file_id: usize,
start: usize,
end: usize,
}
impl LocationKey {
fn from(info: &SourceInfo) -> Option<Self> {
let (file_id, start, end) = info.resolve_byte_range()?;
Some(LocationKey {
file_id,
start,
end,
})
}
}
pub fn coalesce_by_source<I>(input: I) -> Vec<CoalescedDiagnostic>
where
I: IntoIterator<Item = (PathBuf, DiagnosticMessage, Option<SourceContext>)>,
{
let mut groups: Vec<CoalescedDiagnostic> = Vec::new();
let mut index: HashMap<LocationKey, usize> = HashMap::new();
for (path, diagnostic, source_context) in input {
let key = diagnostic.location.as_ref().and_then(LocationKey::from);
match key {
Some(k) => match index.get(&k).copied() {
Some(idx) => {
groups[idx].affected_files.push(path);
}
None => {
let idx = groups.len();
index.insert(k, idx);
groups.push(CoalescedDiagnostic {
representative: diagnostic,
source_context,
affected_files: vec![path],
});
}
},
None => {
groups.push(CoalescedDiagnostic {
representative: diagnostic,
source_context,
affected_files: vec![path],
});
}
}
}
groups
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::DiagnosticMessageBuilder;
use quarto_source_map::{FileId, SourcePiece};
use std::sync::Arc;
fn original(file_id: usize, start: usize, end: usize) -> SourceInfo {
SourceInfo::Original {
file_id: FileId(file_id),
start_offset: start,
end_offset: end,
}
}
fn diag_at(loc: SourceInfo, title: &str) -> DiagnosticMessage {
DiagnosticMessageBuilder::error(title)
.with_code("Q-14-1")
.with_location(loc)
.problem("…")
.build()
}
#[test]
fn two_diagnostics_at_the_same_location_collapse() {
let loc = original(1, 100, 110);
let input = vec![
(PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), None),
(PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 1);
assert_eq!(
groups[0].affected_files,
vec![PathBuf::from("a.qmd"), PathBuf::from("b.qmd"),]
);
}
#[test]
fn different_locations_do_not_collapse() {
let input = vec![
(
PathBuf::from("a.qmd"),
diag_at(original(1, 100, 110), "T"),
None,
),
(
PathBuf::from("b.qmd"),
diag_at(original(1, 200, 210), "T"),
None,
),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 2);
}
#[test]
fn different_file_ids_do_not_collapse() {
let input = vec![
(
PathBuf::from("a.qmd"),
diag_at(original(1, 100, 110), "T"),
None,
),
(
PathBuf::from("b.qmd"),
diag_at(original(2, 100, 110), "T"),
None,
),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 2);
}
#[test]
fn substring_resolves_to_root_original_and_groups_with_it() {
let root = original(1, 100, 200);
let sub = SourceInfo::Substring {
parent: Arc::new(root.clone()),
start_offset: 0,
end_offset: 10,
};
let input = vec![
(PathBuf::from("a.qmd"), diag_at(root.clone(), "T"), None),
(PathBuf::from("b.qmd"), diag_at(sub, "T"), None),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 2);
}
#[test]
fn concat_location_passes_through_as_singleton() {
let concat = SourceInfo::Concat {
pieces: vec![SourcePiece {
source_info: original(1, 0, 10),
offset_in_concat: 0,
length: 10,
}],
};
let input = vec![
(PathBuf::from("a.qmd"), diag_at(concat.clone(), "T"), None),
(PathBuf::from("b.qmd"), diag_at(concat, "T"), None),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].affected_files, vec![PathBuf::from("a.qmd")]);
assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
}
#[test]
fn diagnostics_without_location_pass_through_as_singletons() {
let d = DiagnosticMessageBuilder::error("no location")
.problem("…")
.build();
let input = vec![
(PathBuf::from("a.qmd"), d.clone(), None),
(PathBuf::from("b.qmd"), d, None),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 2);
}
#[test]
fn encounter_order_preserved_across_groups() {
let loc1 = original(1, 100, 110);
let loc2 = original(1, 200, 210);
let input = vec![
(PathBuf::from("a.qmd"), diag_at(loc1.clone(), "T1"), None),
(PathBuf::from("b.qmd"), diag_at(loc2.clone(), "T2"), None),
(PathBuf::from("c.qmd"), diag_at(loc1.clone(), "T1"), None),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].representative.title, "T1");
assert_eq!(
groups[0].affected_files,
vec![PathBuf::from("a.qmd"), PathBuf::from("c.qmd"),]
);
assert_eq!(groups[1].representative.title, "T2");
assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
}
#[test]
fn first_encounter_supplies_representative_and_context() {
let loc = original(1, 100, 110);
let mut ctx_first = SourceContext::new();
ctx_first.add_file_with_id(FileId(1), "first.yml".into(), Some("first".into()));
let mut ctx_second = SourceContext::new();
ctx_second.add_file_with_id(FileId(1), "second.yml".into(), Some("second".into()));
let input = vec![
(
PathBuf::from("a.qmd"),
diag_at(loc.clone(), "first"),
Some(ctx_first),
),
(
PathBuf::from("b.qmd"),
diag_at(loc.clone(), "second"),
Some(ctx_second),
),
];
let groups = coalesce_by_source(input);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].representative.title, "first");
assert!(groups[0].source_context.is_some());
}
#[test]
fn singleton_group_omits_affected_files_tail() {
let loc = original(1, 100, 110);
let input = vec![(PathBuf::from("a.qmd"), diag_at(loc, "T"), None)];
let groups = coalesce_by_source(input);
let opts = TextRenderOptions {
enable_hyperlinks: false,
};
let text = groups[0].to_text_with_options(&opts);
assert!(
!text.contains("Affected files:"),
"singleton groups must not emit the affected-files tail:\n{}",
text
);
}
#[test]
fn multi_group_below_cap_lists_all_files() {
let loc = original(1, 100, 110);
let input = vec![
(PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), None),
(PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
];
let groups = coalesce_by_source(input);
let opts = TextRenderOptions {
enable_hyperlinks: false,
};
let text = groups[0].to_text_with_options(&opts);
assert!(text.contains("Affected files: a.qmd, b.qmd"), "{}", text);
assert!(
!text.contains("other"),
"no '(and N others)' tail expected for ≤ cap:\n{}",
text
);
}
#[test]
fn multi_group_above_cap_truncates_and_counts() {
let loc = original(1, 100, 110);
let input: Vec<_> = ["a", "b", "c", "d", "e"]
.iter()
.map(|n| {
(
PathBuf::from(format!("{n}.qmd")),
diag_at(loc.clone(), "T"),
None,
)
})
.collect();
let groups = coalesce_by_source(input);
let opts = TextRenderOptions {
enable_hyperlinks: false,
};
let text = groups[0].to_text_with_options(&opts);
assert!(
text.contains("Affected files: a.qmd, b.qmd, c.qmd (and 2 others)"),
"{}",
text,
);
}
#[test]
fn multi_group_just_above_cap_uses_singular_other() {
let loc = original(1, 100, 110);
let input: Vec<_> = ["a", "b", "c", "d"]
.iter()
.map(|n| {
(
PathBuf::from(format!("{n}.qmd")),
diag_at(loc.clone(), "T"),
None,
)
})
.collect();
let groups = coalesce_by_source(input);
let opts = TextRenderOptions {
enable_hyperlinks: false,
};
let text = groups[0].to_text_with_options(&opts);
assert!(
text.contains("(and 1 other)"),
"expected singular 'other' for exactly 1 over cap:\n{}",
text,
);
}
}