1use std::collections::HashMap;
68use std::path::PathBuf;
69
70use quarto_source_map::{FileId, SourceContext, SourceInfo};
71
72use crate::diagnostic::{DiagnosticMessage, TextRenderOptions};
73
74#[derive(Debug, Clone)]
82pub struct CoalescedDiagnostic {
83 pub representative: DiagnosticMessage,
84 pub source_context: Option<SourceContext>,
85 pub affected_files: Vec<PathBuf>,
86}
87
88pub const AFFECTED_FILES_CAP: usize = 3;
94
95impl CoalescedDiagnostic {
96 pub fn to_text(&self) -> String {
101 self.to_text_with_options(&TextRenderOptions::default())
102 }
103
104 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145enum FileKey {
146 Path(String),
149 Raw(usize),
153}
154
155#[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
179pub 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 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 let root = original(1, 100, 200);
315 let sub = SourceInfo::Substring {
316 parent: Arc::new(root.clone()),
317 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 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 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 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 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 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 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 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 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 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}