Skip to main content

brink_driver/
diagnostics.rs

1//! Diagnostic collection, suppression, and partitioning.
2
3use brink_analyzer::AnalysisResult;
4use brink_db::{FileDiagnostics, ProjectDb, partition_diagnostics};
5use brink_ir::{Diagnostic, FileId};
6
7/// Partitioned diagnostics after suppression filtering.
8pub struct DiagnosticReport {
9    /// Diagnostics with `Severity::Error`.
10    pub errors: Vec<Diagnostic>,
11    /// Everything else — `Severity::Warning`, and (issue #1162) a
12    /// `Warning`-default code down-leveled by `[lints]` to `Severity::Info`
13    /// or `Severity::Hint` still bisects into this bucket: partitioning is
14    /// binary (`effective_severity(...) == Error` or not), not a per-tier
15    /// split.
16    pub warnings: Vec<Diagnostic>,
17}
18
19/// Collect all diagnostics (lowering + analysis), apply suppressions, partition.
20///
21/// `entry`: if `Some`, checks its suppressions for `disable_all` (compiler mode).
22///          if `None`, analysis diagnostics are always included (LSP mode).
23///
24/// The partitioning core is shared with the db's `lir` query
25/// ([`brink_db::partition_diagnostics`]) so the two paths cannot drift.
26pub fn collect_diagnostics(
27    db: &ProjectDb,
28    analysis: &AnalysisResult,
29    entry: Option<FileId>,
30) -> DiagnosticReport {
31    // Check if the entry file has brink-disable-all
32    let disable_all = entry
33        .and_then(|id| db.suppressions(id))
34        .is_some_and(|s| s.disable_all);
35
36    let inputs: Vec<FileDiagnostics<'_>> = db
37        .file_ids()
38        .filter_map(|id| {
39            Some(FileDiagnostics {
40                file: id,
41                source: db.source(id)?,
42                suppressions: db.suppressions(id)?,
43                lowering: db.file_diagnostics(id)?,
44            })
45        })
46        .collect();
47
48    let opts = db.analysis_options();
49    let types = opts.type_policy();
50    let (errors, warnings) = partition_diagnostics(
51        &inputs,
52        &analysis.diagnostics,
53        disable_all,
54        types,
55        &opts.lints,
56    );
57    DiagnosticReport { errors, warnings }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use brink_analyzer::AnalysisResult;
64    use brink_db::ProjectDb;
65
66    fn empty_analysis() -> AnalysisResult {
67        AnalysisResult {
68            index: std::sync::Arc::new(brink_ir::SymbolIndex::default()),
69            resolutions: Vec::new(),
70            diagnostics: Vec::new(),
71            symbol_meta: std::collections::BTreeMap::new(),
72        }
73    }
74
75    #[test]
76    fn empty_db_returns_empty_report() {
77        let db = ProjectDb::new();
78        let analysis = empty_analysis();
79        let report = collect_diagnostics(&db, &analysis, None);
80        assert!(report.errors.is_empty());
81        assert!(report.warnings.is_empty());
82    }
83
84    #[test]
85    fn lowering_errors_partitioned_correctly() {
86        let mut db = ProjectDb::new();
87        // A file with a parse error (missing knot name)
88        db.set_file("test.ink", "=== \nHello\n".to_string());
89        let analysis = empty_analysis();
90        let entry = db.file_id("test.ink");
91        let report = collect_diagnostics(&db, &analysis, entry);
92        // The missing knot name should produce an error
93        assert!(!report.errors.is_empty());
94    }
95
96    fn run_analysis(db: &ProjectDb) -> AnalysisResult {
97        let inputs = db.analysis_inputs();
98        let file_refs: Vec<_> = inputs
99            .iter()
100            .map(|(id, hir, manifest)| (*id, hir, manifest))
101            .collect();
102        brink_analyzer::analyze(&file_refs)
103    }
104
105    #[test]
106    fn analysis_diagnostics_included_when_no_disable_all() {
107        let mut db = ProjectDb::new();
108        // A file with an unresolved divert target (will produce analysis diagnostic)
109        db.set_file("test.ink", "-> missing_knot\n".to_string());
110        let analysis_result = run_analysis(&db);
111        let entry = db.file_id("test.ink");
112        let report = collect_diagnostics(&db, &analysis_result, entry);
113        // Should have the unresolved divert as an error
114        let total = report.errors.len() + report.warnings.len();
115        assert!(total > 0);
116    }
117
118    #[test]
119    fn disable_all_skips_analysis_diagnostics() {
120        let mut db = ProjectDb::new();
121        // brink-disable-all suppresses analysis diagnostics
122        db.set_file(
123            "test.ink",
124            "// brink-disable-all\n-> missing_knot\n".to_string(),
125        );
126        let analysis_result = run_analysis(&db);
127        let entry = db.file_id("test.ink");
128        let report = collect_diagnostics(&db, &analysis_result, entry);
129        // Analysis diagnostics should be skipped; only lowering diagnostics remain
130        // The lowering diag for the unresolved divert is a lowering error, not analysis
131        // So we just verify no analysis-level diagnostics leaked through
132        let analysis_diag_count = analysis_result.diagnostics.len();
133        // With disable_all, analysis diagnostics should not appear in the report
134        let report_total = report.errors.len() + report.warnings.len();
135        // The report total should be less than if we included analysis diagnostics
136        // (unless there are no analysis diagnostics at all)
137        if analysis_diag_count > 0 {
138            let report_without_disable = collect_diagnostics(&db, &analysis_result, None);
139            let without_total =
140                report_without_disable.errors.len() + report_without_disable.warnings.len();
141            assert!(report_total < without_total);
142        }
143    }
144
145    /// Regression test for #43: a diagnostic originating in an included
146    /// (non-entry) file must be attributed to *that* file, not collapsed onto
147    /// the entry file. The studio currently shows every included-file error on
148    /// the entry (`main.ink`), which makes multi-file errors unlocatable.
149    #[test]
150    fn diagnostic_from_included_file_carries_its_file_id() {
151        let mut db = ProjectDb::new();
152        db.set_file("main.ink", "INCLUDE helper.ink\n-> top\n".to_string());
153        db.set_file("helper.ink", "=== top ===\n-> does_not_exist\n".to_string());
154        let analysis = run_analysis(&db);
155        let entry = db.file_id("main.ink");
156        let helper = db
157            .file_id("helper.ink")
158            .expect("helper.ink should have a FileId");
159        let report = collect_diagnostics(&db, &analysis, entry);
160
161        let all: Vec<_> = report.errors.iter().chain(report.warnings.iter()).collect();
162        assert!(
163            !all.is_empty(),
164            "the unresolved divert in helper.ink should produce a diagnostic"
165        );
166        // The error is wholly within helper.ink, so every diagnostic it produces
167        // must be attributed to helper.ink — not the entry file.
168        for d in &all {
169            assert_eq!(
170                d.file, helper,
171                "diagnostic `{}` for an error inside helper.ink should carry \
172                 helper.ink's FileId ({:?}), not the entry's ({:?})",
173                d.message, helper, entry
174            );
175        }
176    }
177
178    /// Regression test for #187 (secondary): an *analysis* diagnostic (E033,
179    /// unreachable code) originating in an included file must be attributed to
180    /// that file, not the entry. The original report saw such warnings collapsed
181    /// onto `main.ink` at an offset past its EOF. This guards the analysis path
182    /// specifically — #43 only covered lowering diagnostics.
183    #[test]
184    fn analysis_diagnostic_from_included_file_carries_its_file_id() {
185        let mut db = ProjectDb::new();
186        db.set_file("main.ink", "INCLUDE helper.ink\n-> top\n".to_string());
187        // `-> END` is terminal; the following content is unreachable → E033.
188        db.set_file(
189            "helper.ink",
190            "=== top ===\n-> END\nunreachable line\n".to_string(),
191        );
192        let analysis = run_analysis(&db);
193        let entry = db.file_id("main.ink");
194        let helper = db
195            .file_id("helper.ink")
196            .expect("helper.ink should have a FileId");
197        let report = collect_diagnostics(&db, &analysis, entry);
198
199        let e033s: Vec<_> = report
200            .errors
201            .iter()
202            .chain(report.warnings.iter())
203            .filter(|d| d.code == brink_ir::DiagnosticCode::E033)
204            .collect();
205        assert!(
206            !e033s.is_empty(),
207            "the unreachable line in helper.ink should produce an E033"
208        );
209        for d in &e033s {
210            assert_eq!(
211                d.file, helper,
212                "E033 for unreachable code inside helper.ink should carry \
213                 helper.ink's FileId ({helper:?}), not the entry's ({entry:?})"
214            );
215        }
216    }
217}