1use std::cmp::Ordering;
9use std::collections::HashMap;
10use std::fmt;
11use std::sync::Arc;
12
13use crate::source::{
14 BytePosition, Position, PositionEncoding, PositionError, SourceDocument, SourceId,
15 SourceOrigin, SourceRange, SourceRangeError, SourceSet,
16};
17
18use super::{DiagCode, ReportingLevel, Severity};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Diagnostic {
28 pub severity: Severity,
30 pub code: DiagCode,
32 pub message: String,
34 pub module: Option<String>,
36 pub range: Option<SourceRange>,
38}
39
40impl fmt::Display for Diagnostic {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(f, "[{}]", self.severity)?;
45 if let Some(module) = &self.module {
46 write!(f, " {module}:")?;
47 }
48 write!(f, " {}", self.message)
49 }
50}
51
52#[derive(Debug, Clone)]
81pub struct DiagnosticReport {
82 diagnostics: Vec<Diagnostic>,
83 sources: Arc<SourceSet>,
84}
85
86impl DiagnosticReport {
87 pub(crate) fn new(mut diagnostics: Vec<Diagnostic>, sources: Arc<SourceSet>) -> Self {
88 sort_diagnostics(&mut diagnostics, &sources);
89 Self {
90 diagnostics,
91 sources,
92 }
93 }
94
95 pub fn len(&self) -> usize {
97 self.diagnostics.len()
98 }
99
100 pub fn is_empty(&self) -> bool {
102 self.diagnostics.is_empty()
103 }
104
105 pub fn diagnostics(&self) -> &[Diagnostic] {
110 &self.diagnostics
111 }
112
113 pub fn iter(&self) -> impl ExactSizeIterator<Item = DiagnosticEntry<'_>> + DoubleEndedIterator {
115 (0..self.diagnostics.len()).map(|index| DiagnosticEntry {
116 report: self,
117 index,
118 })
119 }
120
121 pub fn get(&self, index: usize) -> Option<DiagnosticEntry<'_>> {
123 (index < self.diagnostics.len()).then_some(DiagnosticEntry {
124 report: self,
125 index,
126 })
127 }
128
129 #[cfg(test)]
130 pub(crate) fn shared_sources(&self) -> &Arc<SourceSet> {
131 &self.sources
132 }
133}
134
135#[derive(Clone, Copy, Debug)]
142pub struct DiagnosticEntry<'report> {
143 report: &'report DiagnosticReport,
144 index: usize,
145}
146
147impl<'report> DiagnosticEntry<'report> {
148 pub fn diagnostic(&self) -> &'report Diagnostic {
150 &self.report.diagnostics[self.index]
151 }
152
153 pub fn range(
155 &self,
156 ) -> Result<Option<(&'report SourceDocument, SourceRange)>, DiagnosticReportError> {
157 let diagnostic = self.diagnostic();
158 let Some(range) = diagnostic.range else {
159 return Ok(None);
160 };
161 let source = self
162 .report
163 .sources
164 .get(range.source())
165 .ok_or(DiagnosticReportError::SourceNotRetained(range.source()))?;
166 source.slice(range)?;
167 Ok(Some((source, range)))
168 }
169
170 pub fn slice(&self) -> Result<Option<&'report [u8]>, DiagnosticReportError> {
172 self.range()?
173 .map(|(source, range)| source.slice(range).map_err(Into::into))
174 .transpose()
175 }
176
177 pub fn byte_positions(
179 &self,
180 ) -> Result<Option<(BytePosition, BytePosition)>, DiagnosticReportError> {
181 self.range()?
182 .map(|(source, range)| {
183 Ok((
184 source.byte_position(range.start())?,
185 source.byte_position(range.end())?,
186 ))
187 })
188 .transpose()
189 }
190
191 pub fn positions(
193 &self,
194 encoding: PositionEncoding,
195 ) -> Result<Option<(Position, Position)>, DiagnosticReportError> {
196 self.range()?
197 .map(|(source, range)| {
198 Ok((
199 source.position(range.start(), encoding)?,
200 source.position(range.end(), encoding)?,
201 ))
202 })
203 .transpose()
204 }
205
206 pub fn render(&self) -> Result<String, DiagnosticReportError> {
211 let diagnostic = self.diagnostic();
212 let mut rendered = format!("[{}]", diagnostic.severity);
213 if let Some((source, _)) = self.range()? {
214 let (start, end) = self
215 .byte_positions()?
216 .expect("a checked source range has byte positions");
217 use std::fmt::Write;
218 write!(
219 rendered,
220 " {}:{}:{}-{}:{}",
221 source.label(),
222 u64::from(start.line()) + 1,
223 u64::from(start.column()) + 1,
224 u64::from(end.line()) + 1,
225 u64::from(end.column()) + 1
226 )
227 .expect("writing to String cannot fail");
228 }
229 if let Some(module) = &diagnostic.module {
230 rendered.push(' ');
231 rendered.push_str(module);
232 }
233 if diagnostic.module.is_some() || diagnostic.range.is_some() {
234 rendered.push(':');
235 }
236 rendered.push(' ');
237 rendered.push_str(&diagnostic.message);
238 Ok(rendered)
239 }
240}
241
242fn sort_diagnostics(diagnostics: &mut [Diagnostic], sources: &SourceSet) {
243 diagnostics.sort_by(|left, right| {
244 left.code
245 .phase()
246 .cmp(right.code.phase())
247 .then_with(|| left.code.as_code().cmp(right.code.as_code()))
248 .then(left.severity.cmp(&right.severity))
249 .then(left.module.cmp(&right.module))
250 .then_with(|| compare_ranges(left.range, right.range, sources))
251 .then(left.message.cmp(&right.message))
252 });
253}
254
255fn compare_ranges(
256 left: Option<SourceRange>,
257 right: Option<SourceRange>,
258 sources: &SourceSet,
259) -> Ordering {
260 match (left, right) {
261 (None, None) => Ordering::Equal,
262 (None, Some(_)) => Ordering::Less,
263 (Some(_), None) => Ordering::Greater,
264 (Some(left), Some(right)) => {
265 let left_source = sources.get(left.source());
266 let right_source = sources.get(right.source());
267 match (left_source, right_source) {
268 (Some(left_source), Some(right_source)) => {
269 compare_origins(left_source.origin(), right_source.origin())
270 .then_with(|| left_source.label().cmp(right_source.label()))
271 .then(left.start().cmp(&right.start()))
272 .then(left.end().cmp(&right.end()))
273 }
274 (Some(_), None) => Ordering::Less,
275 (None, Some(_)) => Ordering::Greater,
276 (None, None) => left
277 .start()
278 .cmp(&right.start())
279 .then(left.end().cmp(&right.end())),
280 }
281 }
282 }
283}
284
285fn compare_origins(left: &SourceOrigin, right: &SourceOrigin) -> Ordering {
286 fn rank(origin: &SourceOrigin) -> u8 {
287 match origin {
288 SourceOrigin::File { .. } => 0,
289 SourceOrigin::Embedded { .. } => 1,
290 SourceOrigin::Memory { .. } => 2,
291 SourceOrigin::Custom { .. } => 3,
292 }
293 }
294
295 rank(left)
296 .cmp(&rank(right))
297 .then_with(|| match (left, right) {
298 (SourceOrigin::File { path: left }, SourceOrigin::File { path: right }) => {
299 left.cmp(right)
300 }
301 (
302 SourceOrigin::Embedded { identity: left },
303 SourceOrigin::Embedded { identity: right },
304 )
305 | (
306 SourceOrigin::Memory { identity: left },
307 SourceOrigin::Memory { identity: right },
308 ) => left.cmp(right),
309 (
310 SourceOrigin::Custom {
311 provider: left_provider,
312 identity: left_identity,
313 },
314 SourceOrigin::Custom {
315 provider: right_provider,
316 identity: right_identity,
317 },
318 ) => left_provider
319 .cmp(right_provider)
320 .then(left_identity.cmp(right_identity)),
321 _ => Ordering::Equal,
322 })
323}
324
325#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
327pub enum DiagnosticReportError {
328 #[error("source {0} is not retained by this diagnostic report")]
330 SourceNotRetained(SourceId),
331 #[error(transparent)]
333 Range(#[from] SourceRangeError),
334 #[error(transparent)]
336 Position(#[from] PositionError),
337}
338
339#[derive(Debug, Clone)]
344pub struct DiagnosticConfig {
345 pub reporting: ReportingLevel,
347 pub fail_at: Severity,
349 pub overrides: HashMap<DiagCode, Severity>,
355 pub ignore: Vec<String>,
357}
358
359impl Default for DiagnosticConfig {
360 fn default() -> Self {
361 DiagnosticConfig {
362 reporting: ReportingLevel::Default,
363 fail_at: Severity::Severe,
364 overrides: HashMap::new(),
365 ignore: Vec::new(),
366 }
367 }
368}
369
370impl DiagnosticConfig {
371 pub fn for_reporting(level: ReportingLevel) -> Self {
373 match level {
374 ReportingLevel::Verbose => Self::verbose(),
375 ReportingLevel::Default => Self::default(),
376 ReportingLevel::Quiet => Self::quiet(),
377 ReportingLevel::Silent => Self::silent(),
378 }
379 }
380
381 pub fn verbose() -> Self {
383 DiagnosticConfig {
384 reporting: ReportingLevel::Verbose,
385 fail_at: Severity::Severe,
386 overrides: HashMap::new(),
387 ignore: Vec::new(),
388 }
389 }
390
391 pub fn quiet() -> Self {
393 DiagnosticConfig {
394 reporting: ReportingLevel::Quiet,
395 fail_at: Severity::Severe,
396 overrides: HashMap::new(),
397 ignore: Vec::new(),
398 }
399 }
400
401 pub fn silent() -> Self {
403 DiagnosticConfig {
404 reporting: ReportingLevel::Silent,
405 fail_at: Severity::Fatal,
406 overrides: HashMap::new(),
407 ignore: Vec::new(),
408 }
409 }
410
411 pub fn effective_severity(&self, code: DiagCode) -> Severity {
416 self.overrides
417 .get(&code)
418 .copied()
419 .unwrap_or_else(|| code.severity())
420 }
421
422 pub fn is_ignored(&self, code: DiagCode) -> bool {
424 let code_str = code.as_code();
425 self.ignore
426 .iter()
427 .any(|pattern| match_glob(pattern, code_str))
428 }
429
430 pub fn should_report(&self, severity: Severity) -> bool {
436 severity == Severity::Fatal
437 || self
438 .max_reported_severity()
439 .is_some_and(|max| severity <= max)
440 }
441
442 pub fn should_collect(&self, code: DiagCode) -> bool {
449 let effective_severity = self.effective_severity(code);
450
451 if effective_severity == Severity::Fatal {
452 return true;
453 }
454
455 if self.is_ignored(code) {
456 return false;
457 }
458
459 self.should_report(code.severity()) || self.should_report(effective_severity)
460 }
461
462 pub fn should_fail(&self, severity: Severity) -> bool {
465 severity <= self.fail_at
466 }
467
468 fn max_reported_severity(&self) -> Option<Severity> {
476 match self.reporting {
477 ReportingLevel::Verbose => Some(Severity::Info),
478 ReportingLevel::Default => Some(Severity::Minor),
479 ReportingLevel::Quiet => Some(Severity::Error),
480 ReportingLevel::Silent => None,
481 }
482 }
483}
484
485fn match_glob(pattern: &str, s: &str) -> bool {
488 glob_match(pattern.as_bytes(), s.as_bytes())
489}
490
491fn glob_match(pattern: &[u8], s: &[u8]) -> bool {
492 let mut pi = 0;
493 let mut si = 0;
494 let mut star_pi: Option<usize> = None;
495 let mut star_si = 0;
496
497 while si < s.len() {
498 if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == s[si]) {
499 pi += 1;
500 si += 1;
501 } else if pi < pattern.len() && pattern[pi] == b'*' {
502 star_pi = Some(pi);
503 star_si = si;
504 pi += 1;
505 } else if let Some(sp) = star_pi {
506 pi = sp + 1;
507 star_si += 1;
508 si = star_si;
509 } else {
510 return false;
511 }
512 }
513
514 while pi < pattern.len() && pattern[pi] == b'*' {
515 pi += 1;
516 }
517
518 pi == pattern.len()
519}
520
521#[cfg(test)]
522mod tests {
523 use std::sync::Arc;
524
525 use crate::source::SourceOrigin;
526
527 use super::*;
528
529 #[test]
530 fn diagnostic_display() {
531 let d = Diagnostic {
532 severity: Severity::Error,
533 code: DiagCode::ImportNotFound,
534 message: "symbol foo not found".to_string(),
535 module: Some("IF-MIB".to_string()),
536 range: None,
537 };
538 assert_eq!(d.to_string(), "[error] IF-MIB: symbol foo not found");
539 }
540
541 #[test]
542 fn diagnostic_display_no_location() {
543 let d = Diagnostic {
544 severity: Severity::Warning,
545 code: DiagCode::ImportUnused,
546 message: "unused import".to_string(),
547 module: None,
548 range: None,
549 };
550 assert_eq!(d.to_string(), "[warning] unused import");
551 }
552
553 #[test]
554 fn report_retains_and_renders_checked_full_range() {
555 let mut sources = SourceSet::new();
556 let source_id = sources
557 .insert(
558 SourceOrigin::memory("diagnostic-report"),
559 "diagnostic-report",
560 Arc::from(&b"first\nsecond"[..]),
561 )
562 .unwrap();
563 let range = sources.get(source_id).unwrap().range(8..10).unwrap();
564 let diagnostic = Diagnostic {
565 severity: Severity::Error,
566 code: DiagCode::ParseError,
567 message: "precise range".to_string(),
568 module: Some("TEST-MIB".to_string()),
569 range: Some(range),
570 };
571 let sources = Arc::new(sources);
572 let report = DiagnosticReport::new(vec![diagnostic], Arc::clone(&sources));
573 drop(sources);
574
575 let entry = report.get(0).unwrap();
576 assert_eq!(entry.slice().unwrap(), Some(&b"co"[..]));
577 assert_eq!(
578 entry.byte_positions().unwrap(),
579 Some((BytePosition::new(1, 2), BytePosition::new(1, 4)))
580 );
581 assert_eq!(
582 entry.render().unwrap(),
583 "[error] diagnostic-report:2:3-2:5 TEST-MIB: precise range"
584 );
585 }
586
587 #[test]
588 fn report_rejects_a_range_whose_source_is_not_retained() {
589 let mut retained = SourceSet::new();
590 retained
591 .insert(
592 SourceOrigin::memory("retained"),
593 "retained",
594 Arc::from(&b"retained"[..]),
595 )
596 .unwrap();
597 let mut foreign = SourceSet::new();
598 foreign
599 .insert(
600 SourceOrigin::memory("foreign-first"),
601 "foreign-first",
602 Arc::from(&b"first"[..]),
603 )
604 .unwrap();
605 let foreign_id = foreign
606 .insert(
607 SourceOrigin::memory("foreign-second"),
608 "foreign-second",
609 Arc::from(&b"second"[..]),
610 )
611 .unwrap();
612 let range = foreign.get(foreign_id).unwrap().range(0..1).unwrap();
613 let diagnostic = Diagnostic {
614 severity: Severity::Error,
615 code: DiagCode::ParseError,
616 message: "foreign".to_string(),
617 module: None,
618 range: Some(range),
619 };
620 let report = DiagnosticReport::new(vec![diagnostic], Arc::new(retained));
621
622 let entry = report.get(0).unwrap();
623 assert!(matches!(
624 entry.range(),
625 Err(DiagnosticReportError::SourceNotRetained(id)) if id == foreign_id
626 ));
627 assert!(matches!(
628 entry.render(),
629 Err(DiagnosticReportError::SourceNotRetained(id)) if id == foreign_id
630 ));
631 }
632
633 #[test]
634 fn report_entries_cannot_cross_resolve_aliased_source_ids() {
635 fn report(identity: &str, bytes: &'static [u8]) -> DiagnosticReport {
636 let mut sources = SourceSet::new();
637 let source_id = sources
638 .insert(SourceOrigin::memory(identity), identity, Arc::from(bytes))
639 .unwrap();
640 let range = sources
641 .get(source_id)
642 .unwrap()
643 .range(0..bytes.len())
644 .unwrap();
645 DiagnosticReport::new(
646 vec![Diagnostic {
647 severity: Severity::Error,
648 code: DiagCode::ParseError,
649 message: identity.to_string(),
650 module: None,
651 range: Some(range),
652 }],
653 Arc::new(sources),
654 )
655 }
656
657 let first = report("first", b"alpha");
658 let second = report("second", b"bravo!");
659 let first_entry = first.get(0).unwrap();
660 let second_entry = second.get(0).unwrap();
661
662 assert_eq!(
663 first_entry.diagnostic().range.unwrap().source(),
664 second_entry.diagnostic().range.unwrap().source()
665 );
666 assert_eq!(first_entry.slice().unwrap(), Some(&b"alpha"[..]));
667 assert_eq!(second_entry.slice().unwrap(), Some(&b"bravo!"[..]));
668 assert_eq!(first_entry.range().unwrap().unwrap().0.label(), "first");
669 assert_eq!(second_entry.range().unwrap().unwrap().0.label(), "second");
670 }
671
672 #[test]
673 fn canonical_order_uses_stable_source_identity_not_source_id_allocation() {
674 fn report(order: [&str; 2]) -> DiagnosticReport {
675 let mut sources = SourceSet::new();
676 for identity in order {
677 sources
678 .insert(
679 SourceOrigin::memory(identity),
680 identity,
681 Arc::from(&b"x"[..]),
682 )
683 .unwrap();
684 }
685 let diagnostics = sources
686 .iter()
687 .map(|source| Diagnostic {
688 severity: Severity::Error,
689 code: DiagCode::ParseError,
690 message: source.label().to_string(),
691 module: Some("TEST-MIB".to_string()),
692 range: Some(source.range(0..1).unwrap()),
693 })
694 .collect();
695 DiagnosticReport::new(diagnostics, Arc::new(sources))
696 }
697
698 let forward = report(["a-source", "b-source"]);
699 let reverse = report(["b-source", "a-source"]);
700 let labels = |report: &DiagnosticReport| {
701 report
702 .iter()
703 .map(|entry| entry.range().unwrap().unwrap().0.label().to_string())
704 .collect::<Vec<_>>()
705 };
706 assert_eq!(labels(&forward), vec!["a-source", "b-source"]);
707 assert_eq!(labels(&reverse), labels(&forward));
708 }
709
710 #[test]
711 fn canonical_order_is_deterministic_for_source_less_diagnostics() {
712 let diagnostic = |message: &str| Diagnostic {
713 severity: Severity::Error,
714 code: DiagCode::ParseError,
715 message: message.to_string(),
716 module: None,
717 range: None,
718 };
719 let report = DiagnosticReport::new(
720 vec![diagnostic("second"), diagnostic("first")],
721 Arc::new(SourceSet::new()),
722 );
723 assert_eq!(
724 report
725 .diagnostics()
726 .iter()
727 .map(|diagnostic| diagnostic.message.as_str())
728 .collect::<Vec<_>>(),
729 vec!["first", "second"]
730 );
731 }
732
733 #[test]
734 fn glob_matching() {
735 assert!(match_glob("identifier-*", "identifier-underscore"));
736 assert!(match_glob("identifier-*", "identifier-length-32"));
737 assert!(!match_glob("identifier-*", "import-not-found"));
738 assert!(match_glob("*", "anything"));
739 assert!(match_glob("exact-match", "exact-match"));
740 assert!(!match_glob("exact-match", "exact-mismatch"));
741 }
742
743 #[test]
744 fn should_report_respects_level() {
745 let config = DiagnosticConfig::default();
746 assert!(config.should_report(Severity::Error));
748 assert!(config.should_report(Severity::Minor));
749 assert!(!config.should_report(Severity::Style));
750 }
751
752 #[test]
753 fn should_report_silent() {
754 let config = DiagnosticConfig::silent();
755 assert!(config.should_report(Severity::Fatal));
757 assert!(!config.should_report(Severity::Error));
758 assert!(!config.should_report(Severity::Style));
759 }
760
761 #[test]
762 fn should_report_verbose() {
763 let config = DiagnosticConfig::verbose();
764 assert!(config.should_report(Severity::Error));
766 assert!(config.should_report(Severity::Style));
767 }
768
769 #[test]
770 fn effective_severity_applies_override() {
771 let mut config = DiagnosticConfig::default();
772 config
773 .overrides
774 .insert(DiagCode::MacroNotImported, Severity::Severe);
775
776 assert_eq!(
777 config.effective_severity(DiagCode::MacroNotImported),
778 Severity::Severe
779 );
780 assert_eq!(
781 config.effective_severity(DiagCode::ParseError),
782 Severity::Error
783 );
784 }
785
786 #[test]
787 fn promotion_affects_collection() {
788 let mut config = DiagnosticConfig::default();
789 config
790 .overrides
791 .insert(DiagCode::IdentifierUnderscore, Severity::Minor);
792
793 assert!(config.should_collect(DiagCode::IdentifierUnderscore));
794 }
795
796 #[test]
797 fn demotion_does_not_discard_collected_diagnostic() {
798 let mut config = DiagnosticConfig::quiet();
799 config
800 .overrides
801 .insert(DiagCode::ParseError, Severity::Info);
802
803 assert!(config.should_collect(DiagCode::ParseError));
804 }
805
806 #[test]
807 fn ignore_suppresses_nonfatal_diagnostic() {
808 let mut config = DiagnosticConfig::verbose();
809 config.ignore.push("parse-*".to_string());
810
811 assert!(config.is_ignored(DiagCode::ParseError));
812 assert!(!config.should_collect(DiagCode::ParseError));
813 }
814
815 #[test]
816 fn effective_fatal_is_always_collected() {
817 let mut config = DiagnosticConfig::silent();
818 config
819 .overrides
820 .insert(DiagCode::IdentifierUnderscore, Severity::Fatal);
821 config.ignore.push("identifier-*".to_string());
822
823 assert!(config.is_ignored(DiagCode::IdentifierUnderscore));
824 assert!(config.should_collect(DiagCode::IdentifierUnderscore));
825 }
826
827 #[test]
828 fn should_fail_threshold() {
829 let config = DiagnosticConfig::default();
830 assert!(config.should_fail(Severity::Fatal));
831 assert!(config.should_fail(Severity::Severe));
832 assert!(!config.should_fail(Severity::Error));
833 }
834
835 #[test]
836 fn for_reporting_presets() {
837 let verbose = DiagnosticConfig::for_reporting(ReportingLevel::Verbose);
838 assert!(matches!(verbose.reporting, ReportingLevel::Verbose));
839
840 let silent = DiagnosticConfig::for_reporting(ReportingLevel::Silent);
841 assert!(matches!(silent.reporting, ReportingLevel::Silent));
842 assert!(matches!(silent.fail_at, Severity::Fatal));
843 }
844}