Skip to main content

brink_driver/
diagnostics.rs

1//! Diagnostic collection, suppression, and partitioning.
2
3use std::collections::HashMap;
4
5use brink_analyzer::AnalysisResult;
6use brink_db::ProjectDb;
7use brink_ir::{Diagnostic, FileId, Severity};
8
9/// Partitioned diagnostics after suppression filtering.
10pub struct DiagnosticReport {
11    /// Diagnostics with `Severity::Error`.
12    pub errors: Vec<Diagnostic>,
13    /// Diagnostics with `Severity::Warning`.
14    pub warnings: Vec<Diagnostic>,
15}
16
17/// Collect all diagnostics (lowering + analysis), apply suppressions, partition.
18///
19/// `entry`: if `Some`, checks its suppressions for `disable_all` (compiler mode).
20///          if `None`, analysis diagnostics are always included (LSP mode).
21pub fn collect_diagnostics(
22    db: &ProjectDb,
23    analysis: &AnalysisResult,
24    entry: Option<FileId>,
25) -> DiagnosticReport {
26    let mut errors = Vec::new();
27    let mut warnings = Vec::new();
28
29    // Check if the entry file has brink-disable-all
30    let disable_all = entry
31        .and_then(|id| db.suppressions(id))
32        .is_some_and(|s| s.disable_all);
33
34    // Per-file lowering diagnostics
35    for id in db.file_ids() {
36        let raw: Vec<Diagnostic> = db.file_diagnostics(id).unwrap_or_default().to_vec();
37        let source = db.source(id).unwrap_or_default();
38        let suppressions = db.suppressions(id).cloned().unwrap_or_default();
39        let filtered = brink_ir::suppressions::apply_suppressions(id, source, raw, &suppressions);
40        for d in filtered {
41            if d.code.severity() == Severity::Error {
42                errors.push(d);
43            } else {
44                warnings.push(d);
45            }
46        }
47    }
48
49    // Analysis diagnostics (unless disable_all)
50    if !disable_all {
51        let mut by_file: HashMap<FileId, Vec<Diagnostic>> = HashMap::new();
52        for d in &analysis.diagnostics {
53            by_file.entry(d.file).or_default().push(d.clone());
54        }
55        // Sort by FileId for determinism
56        let mut file_ids: Vec<_> = by_file.keys().copied().collect();
57        file_ids.sort_by_key(|id| id.0);
58        for fid in file_ids {
59            let diags = by_file.remove(&fid).unwrap_or_default();
60            let source = db.source(fid).unwrap_or_default();
61            let suppressions = db.suppressions(fid).cloned().unwrap_or_default();
62            let filtered =
63                brink_ir::suppressions::apply_suppressions(fid, source, diags, &suppressions);
64            for d in filtered {
65                if d.code.severity() == Severity::Error {
66                    errors.push(d);
67                } else {
68                    warnings.push(d);
69                }
70            }
71        }
72    }
73
74    DiagnosticReport { errors, warnings }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use brink_analyzer::AnalysisResult;
81    use brink_db::ProjectDb;
82
83    fn empty_analysis() -> AnalysisResult {
84        AnalysisResult {
85            index: brink_ir::SymbolIndex::default(),
86            resolutions: Vec::new(),
87            diagnostics: Vec::new(),
88        }
89    }
90
91    #[test]
92    fn empty_db_returns_empty_report() {
93        let db = ProjectDb::new();
94        let analysis = empty_analysis();
95        let report = collect_diagnostics(&db, &analysis, None);
96        assert!(report.errors.is_empty());
97        assert!(report.warnings.is_empty());
98    }
99
100    #[test]
101    fn lowering_errors_partitioned_correctly() {
102        let mut db = ProjectDb::new();
103        // A file with a parse error (missing knot name)
104        db.set_file("test.ink", "=== \nHello\n".to_string());
105        let analysis = empty_analysis();
106        let entry = db.file_id("test.ink");
107        let report = collect_diagnostics(&db, &analysis, entry);
108        // The missing knot name should produce an error
109        assert!(!report.errors.is_empty());
110    }
111
112    fn run_analysis(db: &ProjectDb) -> AnalysisResult {
113        let inputs = db.analysis_inputs();
114        let file_refs: Vec<_> = inputs
115            .iter()
116            .map(|(id, hir, manifest)| (*id, hir, manifest))
117            .collect();
118        brink_analyzer::analyze(&file_refs)
119    }
120
121    #[test]
122    fn analysis_diagnostics_included_when_no_disable_all() {
123        let mut db = ProjectDb::new();
124        // A file with an unresolved divert target (will produce analysis diagnostic)
125        db.set_file("test.ink", "-> missing_knot\n".to_string());
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        // Should have the unresolved divert as an error
130        let total = report.errors.len() + report.warnings.len();
131        assert!(total > 0);
132    }
133
134    #[test]
135    fn disable_all_skips_analysis_diagnostics() {
136        let mut db = ProjectDb::new();
137        // brink-disable-all suppresses analysis diagnostics
138        db.set_file(
139            "test.ink",
140            "// brink-disable-all\n-> missing_knot\n".to_string(),
141        );
142        let analysis_result = run_analysis(&db);
143        let entry = db.file_id("test.ink");
144        let report = collect_diagnostics(&db, &analysis_result, entry);
145        // Analysis diagnostics should be skipped; only lowering diagnostics remain
146        // The lowering diag for the unresolved divert is a lowering error, not analysis
147        // So we just verify no analysis-level diagnostics leaked through
148        let analysis_diag_count = analysis_result.diagnostics.len();
149        // With disable_all, analysis diagnostics should not appear in the report
150        let report_total = report.errors.len() + report.warnings.len();
151        // The report total should be less than if we included analysis diagnostics
152        // (unless there are no analysis diagnostics at all)
153        if analysis_diag_count > 0 {
154            let report_without_disable = collect_diagnostics(&db, &analysis_result, None);
155            let without_total =
156                report_without_disable.errors.len() + report_without_disable.warnings.len();
157            assert!(report_total < without_total);
158        }
159    }
160
161    /// Regression test for #43: a diagnostic originating in an included
162    /// (non-entry) file must be attributed to *that* file, not collapsed onto
163    /// the entry file. The studio currently shows every included-file error on
164    /// the entry (`main.ink`), which makes multi-file errors unlocatable.
165    #[test]
166    fn diagnostic_from_included_file_carries_its_file_id() {
167        let mut db = ProjectDb::new();
168        db.set_file("main.ink", "INCLUDE helper.ink\n-> top\n".to_string());
169        db.set_file("helper.ink", "=== top ===\n-> does_not_exist\n".to_string());
170        let analysis = run_analysis(&db);
171        let entry = db.file_id("main.ink");
172        let helper = db
173            .file_id("helper.ink")
174            .expect("helper.ink should have a FileId");
175        let report = collect_diagnostics(&db, &analysis, entry);
176
177        let all: Vec<_> = report.errors.iter().chain(report.warnings.iter()).collect();
178        assert!(
179            !all.is_empty(),
180            "the unresolved divert in helper.ink should produce a diagnostic"
181        );
182        // The error is wholly within helper.ink, so every diagnostic it produces
183        // must be attributed to helper.ink — not the entry file.
184        for d in &all {
185            assert_eq!(
186                d.file, helper,
187                "diagnostic `{}` for an error inside helper.ink should carry \
188                 helper.ink's FileId ({:?}), not the entry's ({:?})",
189                d.message, helper, entry
190            );
191        }
192    }
193}