Skip to main content

bamts_compiler/
diagnostic.rs

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/// A stable compiler diagnostic identifier.
14///
15/// Codes are static so callers cannot manufacture run-dependent identifiers that
16/// would make diagnostic output unstable across equivalent compilations.
17#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct DiagnosticCode(&'static str);
19
20impl DiagnosticCode {
21    /// Creates a stable diagnostic identifier.
22    #[must_use]
23    pub const fn new(value: &'static str) -> Self {
24        Self(value)
25    }
26
27    /// Returns the canonical identifier text.
28    #[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/// The effect of a diagnostic on compilation.
41#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42pub enum DiagnosticSeverity {
43    /// The source is invalid, but recovery still supplies a product.
44    Error,
45    /// The source is accepted; the compiler reports a non-fatal hard-warning.
46    Warning,
47}
48
49/// How confidently a diagnostic suggestion can be applied.
50#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub enum Applicability {
52    /// The edit is deterministic, behavior-preserving, and formatting-preserving.
53    MachineApplicable,
54    /// The edit has a deterministic shape but requires user-provided values.
55    HasPlaceholders,
56    /// The edit may change behavior or represents one of several valid choices.
57    MaybeIncorrect,
58    /// The remediation cannot be expressed as a localized, confidence-rated edit.
59    Unspecified,
60}
61
62impl Applicability {
63    /// Returns the stable name used by structured diagnostic renderers.
64    #[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/// A source edit proposed by a diagnostic.
82#[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/// A related source location rendered beneath a diagnostic's primary span.
143#[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/// An immutable compiler diagnostic.
214#[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    /// Creates a diagnostic with a stable source anchor and message.
232    #[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    /// Creates a recovered source error.
257    #[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    /// Creates a non-fatal hard-warning.
268    #[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    /// Creates a rule diagnostic at its resolved lint level.
279    ///
280    /// `allow` emits nothing, `warn` remains non-fatal, and `deny`/`forbid`
281    /// produce errors that fail the build.
282    #[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    /// Returns the source containing this diagnostic.
308    #[must_use]
309    pub const fn source_id(&self) -> SourceId {
310        self.source_id
311    }
312
313    /// Returns the immutable source range that triggered this diagnostic.
314    #[must_use]
315    pub const fn range(&self) -> TextRange {
316        self.range
317    }
318
319    /// Returns the stable diagnostic identifier.
320    #[must_use]
321    pub const fn code(&self) -> DiagnosticCode {
322        self.code
323    }
324
325    /// Returns whether this diagnostic is an error or a warning.
326    #[must_use]
327    pub const fn severity(&self) -> DiagnosticSeverity {
328        self.severity
329    }
330
331    /// Returns the exact compiler message.
332    #[must_use]
333    pub const fn message(&self) -> &'static str {
334        self.message
335    }
336
337    /// Attaches the catalog identity and its copy-pasteable silence instruction.
338    #[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    /// Adds a related source span.
349    #[must_use]
350    pub fn with_secondary_span(mut self, span: SecondarySpan) -> Self {
351        self.secondary_spans.push(span);
352        self
353    }
354
355    /// Adds a technical explanation.
356    #[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    /// Adds actionable remediation guidance.
363    #[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    /// Adds a localized source edit with an explicit confidence level.
370    #[must_use]
371    pub fn with_suggestion(mut self, suggestion: Suggestion) -> Self {
372        self.suggestion = Some(suggestion);
373        self
374    }
375
376    /// Marks this diagnostic as the root cause that poisons one AST node.
377    #[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    /// Marks this diagnostic as a consequence of a poisoned AST node.
387    #[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    /// Returns whether this diagnostic is non-fatal.
427    #[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
465/// The maximum number of rendered diagnostics retained for one rule.
466pub const PER_RULE_DIAGNOSTIC_CAP: usize = 50;
467
468/// Aggregate information rendered once for each rule that emitted diagnostics.
469#[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/// Diagnostics prepared for presentation with noise controls applied.
494#[derive(Clone, Debug, Eq, PartialEq)]
495pub struct DiagnosticReport {
496    diagnostics: Vec<Diagnostic>,
497    summaries: Vec<RuleSummary>,
498}
499
500impl DiagnosticReport {
501    /// Deduplicates, suppresses poisoned cascades, caps each rule, and summarizes totals.
502    #[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/// A compiler product retained even when recovery emits diagnostics.
574///
575/// `Recovered` deliberately never uses `Result`: syntax and type problems are
576/// compiler data, while callers always retain an inspectable product.
577#[derive(Clone, Debug, Eq, PartialEq)]
578pub struct Recovered<T> {
579    product: T,
580    diagnostics: Vec<Diagnostic>,
581}
582
583impl<T> Recovered<T> {
584    /// Retains `product` and canonically orders its diagnostics.
585    #[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    /// Wraps a product with no diagnostics.
595    #[must_use]
596    pub fn clean(product: T) -> Self {
597        Self::new(product, Vec::new())
598    }
599
600    /// Returns the recovered product.
601    #[must_use]
602    pub const fn product(&self) -> &T {
603        &self.product
604    }
605
606    /// Consumes the wrapper while retaining the recovered product.
607    #[must_use]
608    pub fn into_product(self) -> T {
609        self.product
610    }
611
612    /// Returns diagnostics in canonical order.
613    #[must_use]
614    pub fn diagnostics(&self) -> &[Diagnostic] {
615        &self.diagnostics
616    }
617
618    /// Consumes the wrapper into its product and canonically ordered diagnostics.
619    #[must_use]
620    pub fn into_parts(self) -> (T, Vec<Diagnostic>) {
621        (self.product, self.diagnostics)
622    }
623
624    /// Transforms the retained product without discarding recovery diagnostics.
625    #[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    /// Returns a new wrapper with one additional diagnostic in canonical order.
634    #[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}