1use std::{
2 cmp::Ordering,
3 collections::{BTreeMap, HashSet},
4 fmt,
5};
6
7use crate::{
8 lint::{LintLevel, RuleId},
9 source::{SourceId, TextRange},
10 syntax::NodeId,
11};
12
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct DiagnosticCode(&'static str);
19
20impl DiagnosticCode {
21 #[must_use]
23 pub const fn new(value: &'static str) -> Self {
24 Self(value)
25 }
26
27 #[must_use]
29 pub const fn as_str(self) -> &'static str {
30 self.0
31 }
32}
33
34impl fmt::Display for DiagnosticCode {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 formatter.write_str(self.0)
37 }
38}
39
40#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42pub enum DiagnosticSeverity {
43 Error,
45 Warning,
47}
48
49#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub enum Applicability {
52 MachineApplicable,
54 HasPlaceholders,
56 MaybeIncorrect,
58 Unspecified,
60}
61
62impl Applicability {
63 #[must_use]
65 pub const fn as_str(self) -> &'static str {
66 match self {
67 Self::MachineApplicable => "MachineApplicable",
68 Self::HasPlaceholders => "HasPlaceholders",
69 Self::MaybeIncorrect => "MaybeIncorrect",
70 Self::Unspecified => "Unspecified",
71 }
72 }
73}
74
75impl fmt::Display for Applicability {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 formatter.write_str(self.as_str())
78 }
79}
80
81#[derive(Clone, Debug, Eq, Hash, PartialEq)]
83pub struct Suggestion {
84 range: TextRange,
85 replacement: String,
86 applicability: Applicability,
87}
88
89impl Suggestion {
90 #[must_use]
91 pub fn new(
92 range: TextRange,
93 replacement: impl Into<String>,
94 applicability: Applicability,
95 ) -> Self {
96 Self {
97 range,
98 replacement: replacement.into(),
99 applicability,
100 }
101 }
102
103 #[must_use]
104 pub const fn range(&self) -> TextRange {
105 self.range
106 }
107
108 #[must_use]
109 pub fn replacement(&self) -> &str {
110 &self.replacement
111 }
112
113 #[must_use]
114 pub const fn applicability(&self) -> Applicability {
115 self.applicability
116 }
117}
118
119impl Ord for Suggestion {
120 fn cmp(&self, other: &Self) -> Ordering {
121 (
122 self.range.start(),
123 self.range.end(),
124 &self.replacement,
125 self.applicability,
126 )
127 .cmp(&(
128 other.range.start(),
129 other.range.end(),
130 &other.replacement,
131 other.applicability,
132 ))
133 }
134}
135
136impl PartialOrd for Suggestion {
137 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
138 Some(self.cmp(other))
139 }
140}
141
142#[derive(Clone, Debug, Eq, Hash, PartialEq)]
144pub struct SecondarySpan {
145 source_id: SourceId,
146 range: TextRange,
147 label: String,
148}
149
150impl SecondarySpan {
151 #[must_use]
152 pub fn new(source_id: SourceId, range: TextRange, label: impl Into<String>) -> Self {
153 Self {
154 source_id,
155 range,
156 label: label.into(),
157 }
158 }
159
160 #[must_use]
161 pub const fn source_id(&self) -> SourceId {
162 self.source_id
163 }
164
165 #[must_use]
166 pub const fn range(&self) -> TextRange {
167 self.range
168 }
169
170 #[must_use]
171 pub fn label(&self) -> &str {
172 &self.label
173 }
174}
175
176impl Ord for SecondarySpan {
177 fn cmp(&self, other: &Self) -> Ordering {
178 (
179 self.source_id,
180 self.range.start(),
181 self.range.end(),
182 &self.label,
183 )
184 .cmp(&(
185 other.source_id,
186 other.range.start(),
187 other.range.end(),
188 &other.label,
189 ))
190 }
191}
192
193impl PartialOrd for SecondarySpan {
194 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
195 Some(self.cmp(other))
196 }
197}
198
199#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
200struct PoisonedNode {
201 source_id: SourceId,
202 node_id: NodeId,
203}
204
205#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
206enum DiagnosticCause {
207 #[default]
208 Independent,
209 Root(PoisonedNode),
210 Downstream(PoisonedNode),
211}
212
213#[derive(Clone, Debug, Eq, Hash, PartialEq)]
215pub struct Diagnostic {
216 source_id: SourceId,
217 range: TextRange,
218 code: DiagnosticCode,
219 severity: DiagnosticSeverity,
220 message: &'static str,
221 rule: Option<RuleId>,
222 secondary_spans: Vec<SecondarySpan>,
223 note: Option<String>,
224 help: Option<String>,
225 suggestion: Option<Suggestion>,
226 silence_instruction: Option<String>,
227 cause: DiagnosticCause,
228}
229
230impl Diagnostic {
231 #[must_use]
233 pub const fn new(
234 severity: DiagnosticSeverity,
235 code: DiagnosticCode,
236 source_id: SourceId,
237 range: TextRange,
238 message: &'static str,
239 ) -> Self {
240 Self {
241 source_id,
242 range,
243 code,
244 severity,
245 message,
246 rule: None,
247 secondary_spans: Vec::new(),
248 note: None,
249 help: None,
250 suggestion: None,
251 silence_instruction: None,
252 cause: DiagnosticCause::Independent,
253 }
254 }
255
256 #[must_use]
258 pub const fn error(
259 code: DiagnosticCode,
260 source_id: SourceId,
261 range: TextRange,
262 message: &'static str,
263 ) -> Self {
264 Self::new(DiagnosticSeverity::Error, code, source_id, range, message)
265 }
266
267 #[must_use]
269 pub const fn warning(
270 code: DiagnosticCode,
271 source_id: SourceId,
272 range: TextRange,
273 message: &'static str,
274 ) -> Self {
275 Self::new(DiagnosticSeverity::Warning, code, source_id, range, message)
276 }
277
278 #[must_use]
283 pub fn lint(
284 level: LintLevel,
285 rule: RuleId,
286 source_id: SourceId,
287 range: TextRange,
288 message: &'static str,
289 ) -> Option<Self> {
290 let severity = match level {
291 LintLevel::Allow => return None,
292 LintLevel::Warn => DiagnosticSeverity::Warning,
293 LintLevel::Deny | LintLevel::Forbid => DiagnosticSeverity::Error,
294 };
295 Some(
296 Self::new(
297 severity,
298 DiagnosticCode::new(rule.code()),
299 source_id,
300 range,
301 message,
302 )
303 .with_rule(rule),
304 )
305 }
306
307 #[must_use]
309 pub const fn source_id(&self) -> SourceId {
310 self.source_id
311 }
312
313 #[must_use]
315 pub const fn range(&self) -> TextRange {
316 self.range
317 }
318
319 #[must_use]
321 pub const fn code(&self) -> DiagnosticCode {
322 self.code
323 }
324
325 #[must_use]
327 pub const fn severity(&self) -> DiagnosticSeverity {
328 self.severity
329 }
330
331 #[must_use]
333 pub const fn message(&self) -> &'static str {
334 self.message
335 }
336
337 #[must_use]
339 pub fn with_rule(mut self, rule: RuleId) -> Self {
340 self.silence_instruction = Some(format!(
341 "pass `-A {slug}` or set `lints.rules.{slug} = \"allow\"` in bamts.toml",
342 slug = rule.slug()
343 ));
344 self.rule = Some(rule);
345 self
346 }
347
348 #[must_use]
350 pub fn with_secondary_span(mut self, span: SecondarySpan) -> Self {
351 self.secondary_spans.push(span);
352 self
353 }
354
355 #[must_use]
357 pub fn with_note(mut self, note: impl Into<String>) -> Self {
358 self.note = Some(note.into());
359 self
360 }
361
362 #[must_use]
364 pub fn with_help(mut self, help: impl Into<String>) -> Self {
365 self.help = Some(help.into());
366 self
367 }
368
369 #[must_use]
371 pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
372 self.suggestion = Some(suggestion);
373 self
374 }
375
376 #[must_use]
378 pub fn poisons(mut self, node_id: NodeId) -> Self {
379 self.cause = DiagnosticCause::Root(PoisonedNode {
380 source_id: self.source_id,
381 node_id,
382 });
383 self
384 }
385
386 #[must_use]
388 pub fn downstream_of(mut self, node_id: NodeId) -> Self {
389 self.cause = DiagnosticCause::Downstream(PoisonedNode {
390 source_id: self.source_id,
391 node_id,
392 });
393 self
394 }
395
396 #[must_use]
397 pub const fn rule(&self) -> Option<RuleId> {
398 self.rule
399 }
400
401 #[must_use]
402 pub fn secondary_spans(&self) -> &[SecondarySpan] {
403 &self.secondary_spans
404 }
405
406 #[must_use]
407 pub fn note(&self) -> Option<&str> {
408 self.note.as_deref()
409 }
410
411 #[must_use]
412 pub fn help(&self) -> Option<&str> {
413 self.help.as_deref()
414 }
415
416 #[must_use]
417 pub const fn suggestion(&self) -> Option<&Suggestion> {
418 self.suggestion.as_ref()
419 }
420
421 #[must_use]
422 pub fn silence_instruction(&self) -> Option<&str> {
423 self.silence_instruction.as_deref()
424 }
425
426 #[must_use]
428 pub const fn is_warning(&self) -> bool {
429 matches!(self.severity, DiagnosticSeverity::Warning)
430 }
431}
432
433impl Ord for Diagnostic {
434 fn cmp(&self, other: &Self) -> Ordering {
435 (
436 self.source_id,
437 self.range.start(),
438 self.range.end(),
439 self.code,
440 )
441 .cmp(&(
442 other.source_id,
443 other.range.start(),
444 other.range.end(),
445 other.code,
446 ))
447 .then_with(|| self.severity.cmp(&other.severity))
448 .then_with(|| self.message.cmp(other.message))
449 .then_with(|| self.rule.cmp(&other.rule))
450 .then_with(|| self.secondary_spans.cmp(&other.secondary_spans))
451 .then_with(|| self.note.cmp(&other.note))
452 .then_with(|| self.help.cmp(&other.help))
453 .then_with(|| self.suggestion.cmp(&other.suggestion))
454 .then_with(|| self.silence_instruction.cmp(&other.silence_instruction))
455 .then_with(|| self.cause.cmp(&other.cause))
456 }
457}
458
459impl PartialOrd for Diagnostic {
460 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
461 Some(self.cmp(other))
462 }
463}
464
465pub const PER_RULE_DIAGNOSTIC_CAP: usize = 50;
467
468#[derive(Clone, Debug, Eq, PartialEq)]
470pub struct RuleSummary {
471 rule: RuleId,
472 total_count: usize,
473 silence_flag: String,
474}
475
476impl RuleSummary {
477 #[must_use]
478 pub const fn rule(&self) -> RuleId {
479 self.rule
480 }
481
482 #[must_use]
483 pub const fn total_count(&self) -> usize {
484 self.total_count
485 }
486
487 #[must_use]
488 pub fn silence_flag(&self) -> &str {
489 &self.silence_flag
490 }
491}
492
493#[derive(Clone, Debug, Eq, PartialEq)]
495pub struct DiagnosticReport {
496 diagnostics: Vec<Diagnostic>,
497 summaries: Vec<RuleSummary>,
498}
499
500impl DiagnosticReport {
501 #[must_use]
503 pub fn new(diagnostics: &[Diagnostic]) -> Self {
504 let poisoned = diagnostics
505 .iter()
506 .filter_map(|diagnostic| match diagnostic.cause {
507 DiagnosticCause::Root(node) => Some(node),
508 DiagnosticCause::Independent | DiagnosticCause::Downstream(_) => None,
509 })
510 .collect::<HashSet<_>>();
511 let mut ordered = diagnostics.to_vec();
512 ordered.sort();
513
514 let mut seen = HashSet::new();
515 let mut totals = BTreeMap::<RuleId, usize>::new();
516 let mut retained_per_rule = BTreeMap::<RuleId, usize>::new();
517 let mut retained = Vec::with_capacity(ordered.len());
518
519 for diagnostic in ordered {
520 if matches!(diagnostic.cause, DiagnosticCause::Downstream(node) if poisoned.contains(&node))
521 {
522 continue;
523 }
524
525 let Some(rule) = diagnostic.rule else {
526 retained.push(diagnostic);
527 continue;
528 };
529 let key = (
530 diagnostic.source_id,
531 diagnostic.range.start(),
532 diagnostic.range.end(),
533 rule,
534 );
535 if !seen.insert(key) {
536 continue;
537 }
538
539 *totals.entry(rule).or_default() += 1;
540 let retained_count = retained_per_rule.entry(rule).or_default();
541 if *retained_count < PER_RULE_DIAGNOSTIC_CAP {
542 *retained_count += 1;
543 retained.push(diagnostic);
544 }
545 }
546
547 let summaries = totals
548 .into_iter()
549 .map(|(rule, total_count)| RuleSummary {
550 rule,
551 total_count,
552 silence_flag: format!("-A {}", rule.slug()),
553 })
554 .collect();
555
556 Self {
557 diagnostics: retained,
558 summaries,
559 }
560 }
561
562 #[must_use]
563 pub fn diagnostics(&self) -> &[Diagnostic] {
564 &self.diagnostics
565 }
566
567 #[must_use]
568 pub fn summaries(&self) -> &[RuleSummary] {
569 &self.summaries
570 }
571}
572
573#[derive(Clone, Debug, Eq, PartialEq)]
578pub struct Recovered<T> {
579 product: T,
580 diagnostics: Vec<Diagnostic>,
581}
582
583impl<T> Recovered<T> {
584 #[must_use]
586 pub fn new(product: T, mut diagnostics: Vec<Diagnostic>) -> Self {
587 diagnostics.sort();
588 Self {
589 product,
590 diagnostics,
591 }
592 }
593
594 #[must_use]
596 pub fn clean(product: T) -> Self {
597 Self::new(product, Vec::new())
598 }
599
600 #[must_use]
602 pub const fn product(&self) -> &T {
603 &self.product
604 }
605
606 #[must_use]
608 pub fn into_product(self) -> T {
609 self.product
610 }
611
612 #[must_use]
614 pub fn diagnostics(&self) -> &[Diagnostic] {
615 &self.diagnostics
616 }
617
618 #[must_use]
620 pub fn into_parts(self) -> (T, Vec<Diagnostic>) {
621 (self.product, self.diagnostics)
622 }
623
624 #[must_use]
626 pub fn map<U>(self, transform: impl FnOnce(T) -> U) -> Recovered<U> {
627 Recovered {
628 product: transform(self.product),
629 diagnostics: self.diagnostics,
630 }
631 }
632
633 #[must_use]
635 pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
636 let insertion = self
637 .diagnostics
638 .partition_point(|existing| existing <= &diagnostic);
639 self.diagnostics.insert(insertion, diagnostic);
640 self
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::{
647 Applicability, Diagnostic, DiagnosticCode, DiagnosticReport, DiagnosticSeverity,
648 PER_RULE_DIAGNOSTIC_CAP, Recovered, SecondarySpan, Suggestion,
649 };
650 use crate::{
651 lint::{LintLevel, RULES, RuleId},
652 source::{SourceId, TextRange, Utf16Pos},
653 syntax::NodeId,
654 };
655
656 fn range(start: usize, end: usize) -> TextRange {
657 TextRange::new(Utf16Pos::new(start), Utf16Pos::new(end)).expect("ordered test range")
658 }
659
660 fn rule() -> RuleId {
661 RULES[0].id()
662 }
663
664 fn rule_diagnostic(start: usize, end: usize) -> Diagnostic {
665 let rule = rule();
666 Diagnostic::warning(
667 DiagnosticCode::new(rule.code()),
668 SourceId::new(0),
669 range(start, end),
670 "rule diagnostic",
671 )
672 .with_rule(rule)
673 }
674
675 #[test]
676 fn recovered_keeps_the_product_alongside_errors() {
677 let recovered = Recovered::new(
678 String::from("usable syntax tree"),
679 vec![Diagnostic::error(
680 DiagnosticCode::new("BAMTS-E001"),
681 SourceId::new(0),
682 range(0, 1),
683 "expected expression",
684 )],
685 );
686
687 assert_eq!(recovered.product(), "usable syntax tree");
688 assert_eq!(recovered.diagnostics().len(), 1);
689 assert_eq!(
690 recovered.diagnostics()[0].severity(),
691 DiagnosticSeverity::Error
692 );
693 }
694
695 #[test]
696 fn diagnostics_sort_by_the_contract_key() {
697 let recovered = Recovered::new(
698 (),
699 vec![
700 Diagnostic::warning(
701 DiagnosticCode::new("BAMTS-W007"),
702 SourceId::new(1),
703 range(0, 1),
704 "later source",
705 ),
706 Diagnostic::warning(
707 DiagnosticCode::new("BAMTS-W002"),
708 SourceId::new(0),
709 range(3, 4),
710 "later position",
711 ),
712 Diagnostic::warning(
713 DiagnosticCode::new("BAMTS-W003"),
714 SourceId::new(0),
715 range(1, 2),
716 "higher code",
717 ),
718 Diagnostic::warning(
719 DiagnosticCode::new("BAMTS-W001"),
720 SourceId::new(0),
721 range(1, 2),
722 "lower code",
723 ),
724 ],
725 );
726
727 let codes = recovered
728 .diagnostics()
729 .iter()
730 .map(|diagnostic| diagnostic.code().as_str())
731 .collect::<Vec<_>>();
732 assert_eq!(
733 codes,
734 ["BAMTS-W001", "BAMTS-W003", "BAMTS-W002", "BAMTS-W007"]
735 );
736 }
737
738 #[test]
739 fn warnings_remain_warnings() {
740 let warning = Diagnostic::warning(
741 DiagnosticCode::new("BAMTS-W001"),
742 SourceId::new(0),
743 range(0, 1),
744 "method parameter bivariance",
745 );
746
747 assert!(warning.is_warning());
748 assert_eq!(warning.severity(), DiagnosticSeverity::Warning);
749 }
750
751 #[test]
752 fn lint_constructor_maps_all_levels_to_build_behavior() {
753 assert!(
754 Diagnostic::lint(
755 LintLevel::Allow,
756 rule(),
757 SourceId::new(0),
758 range(0, 1),
759 "allowed",
760 )
761 .is_none()
762 );
763 let warning = Diagnostic::lint(
764 LintLevel::Warn,
765 rule(),
766 SourceId::new(0),
767 range(0, 1),
768 "warning",
769 )
770 .expect("warn emits");
771 assert_eq!(warning.severity(), DiagnosticSeverity::Warning);
772
773 for level in [LintLevel::Deny, LintLevel::Forbid] {
774 let denied = Diagnostic::lint(level, rule(), SourceId::new(0), range(0, 1), "denied")
775 .expect("deny and forbid emit");
776 assert_eq!(denied.severity(), DiagnosticSeverity::Error);
777 }
778 }
779
780 #[test]
781 fn structured_rule_metadata_is_available_to_renderers() {
782 let diagnostic = rule_diagnostic(1, 3)
783 .with_secondary_span(SecondarySpan::new(
784 SourceId::new(1),
785 range(4, 8),
786 "related declaration",
787 ))
788 .with_note("technical explanation")
789 .with_help("actionable remediation")
790 .with_suggestion(Suggestion::new(
791 range(1, 3),
792 "replacement",
793 Applicability::MachineApplicable,
794 ));
795
796 assert_eq!(diagnostic.rule(), Some(rule()));
797 assert_eq!(
798 diagnostic.secondary_spans()[0].source_id(),
799 SourceId::new(1)
800 );
801 assert_eq!(diagnostic.secondary_spans()[0].range(), range(4, 8));
802 assert_eq!(
803 diagnostic.secondary_spans()[0].label(),
804 "related declaration"
805 );
806 assert_eq!(diagnostic.note(), Some("technical explanation"));
807 assert_eq!(diagnostic.help(), Some("actionable remediation"));
808 assert_eq!(
809 diagnostic.suggestion().map(Suggestion::replacement),
810 Some("replacement")
811 );
812 assert_eq!(
813 diagnostic.silence_instruction(),
814 Some(
815 format!(
816 "pass `-A {slug}` or set `lints.rules.{slug} = \"allow\"` in bamts.toml",
817 slug = rule().slug()
818 )
819 .as_str()
820 )
821 );
822 }
823
824 #[test]
825 fn all_suggestion_applicability_levels_have_stable_rendered_names() {
826 let levels = [
827 (Applicability::MachineApplicable, "MachineApplicable"),
828 (Applicability::HasPlaceholders, "HasPlaceholders"),
829 (Applicability::MaybeIncorrect, "MaybeIncorrect"),
830 (Applicability::Unspecified, "Unspecified"),
831 ];
832
833 for (level, rendered) in levels {
834 let suggestion = Suggestion::new(range(0, 1), "x", level);
835 assert_eq!(suggestion.applicability(), level);
836 assert_eq!(suggestion.range(), range(0, 1));
837 assert_eq!(level.as_str(), rendered);
838 assert_eq!(level.to_string(), rendered);
839 }
840 }
841
842 #[test]
843 fn report_deduplicates_by_source_span_and_rule() {
844 let duplicate_with_another_message = Diagnostic::warning(
845 DiagnosticCode::new(rule().code()),
846 SourceId::new(0),
847 range(2, 4),
848 "another checker pass",
849 )
850 .with_rule(rule());
851 let report = DiagnosticReport::new(&[
852 rule_diagnostic(2, 4),
853 duplicate_with_another_message,
854 rule_diagnostic(2, 5),
855 ]);
856
857 assert_eq!(report.diagnostics().len(), 2);
858 assert_eq!(report.summaries()[0].total_count(), 2);
859 }
860
861 #[test]
862 fn report_suppresses_diagnostics_downstream_of_a_poisoned_root() {
863 let poisoned_node = NodeId::new(7);
864 let report = DiagnosticReport::new(&[
865 rule_diagnostic(0, 1).poisons(poisoned_node),
866 rule_diagnostic(1, 2).downstream_of(poisoned_node),
867 rule_diagnostic(2, 3).downstream_of(poisoned_node),
868 rule_diagnostic(3, 4).downstream_of(poisoned_node),
869 rule_diagnostic(4, 5).downstream_of(poisoned_node),
870 rule_diagnostic(5, 6).downstream_of(NodeId::new(8)),
871 ]);
872
873 assert_eq!(report.diagnostics().len(), 2);
874 assert_eq!(report.summaries()[0].total_count(), 2);
875 assert_eq!(report.diagnostics()[0].range(), range(0, 1));
876 assert_eq!(report.diagnostics()[1].range(), range(5, 6));
877 }
878
879 #[test]
880 fn report_caps_each_rule_and_summarizes_the_uncapped_total() {
881 let diagnostics = (0..(PER_RULE_DIAGNOSTIC_CAP + 5))
882 .map(|position| rule_diagnostic(position, position + 1))
883 .collect::<Vec<_>>();
884 let report = DiagnosticReport::new(&diagnostics);
885
886 assert_eq!(report.diagnostics().len(), PER_RULE_DIAGNOSTIC_CAP);
887 assert_eq!(report.summaries().len(), 1);
888 let summary = &report.summaries()[0];
889 assert_eq!(summary.rule(), rule());
890 assert_eq!(summary.total_count(), PER_RULE_DIAGNOSTIC_CAP + 5);
891 assert_eq!(summary.silence_flag(), format!("-A {}", rule().slug()));
892 }
893}