Skip to main content

brink_driver/
lib.rs

1//! Pipeline orchestration for the brink ink compiler.
2//!
3//! `Driver` wraps a `ProjectDb` and provides higher-level operations:
4//! file discovery, analysis orchestration, diagnostic collection, and
5//! LIR input preparation. Both the compiler (one-shot) and LSP (long-lived)
6//! use `Driver` as their entry point.
7
8mod diagnostics;
9mod discover;
10mod discover_native;
11mod source_tree;
12
13use std::collections::HashMap;
14use std::io;
15
16pub use brink_analyzer::{
17    AnalysisOptions, AnalysisResult, Dialect, LintLevel, LintPolicy, TypePolicy, effective_severity,
18};
19pub use brink_db::{CompileProduct, LirProduct, ProjectDb, SourceTree};
20pub use brink_ir::FileId;
21pub use diagnostics::DiagnosticReport;
22pub use discover::DiscoverError;
23pub use source_tree::{
24    GitRev, RealFs, is_native, native_source_root, native_source_root_with_warnings, relative_key,
25};
26
27/// Pipeline orchestration wrapper around `ProjectDb`.
28pub struct Driver {
29    db: ProjectDb,
30}
31
32impl Driver {
33    /// Create a new driver with an empty database.
34    pub fn new() -> Self {
35        Self {
36            db: ProjectDb::new(),
37        }
38    }
39
40    /// Create a driver from an existing database.
41    pub fn from_db(db: ProjectDb) -> Self {
42        Self { db }
43    }
44
45    /// Set the analysis options (e.g. a registered host manifest + external
46    /// check severity) used by [`analyze`](Self::analyze). An input write —
47    /// dependent queries recompute on next read.
48    pub fn set_analysis_options(&mut self, options: AnalysisOptions) {
49        self.db.set_analysis_options(options);
50    }
51
52    /// Borrow the underlying database.
53    pub fn db(&self) -> &ProjectDb {
54        &self.db
55    }
56
57    /// Mutably borrow the underlying database.
58    ///
59    /// Salsa's dependency tracking invalidates derived queries on input
60    /// writes, so no manual cache invalidation happens here.
61    pub fn db_mut(&mut self) -> &mut ProjectDb {
62        &mut self.db
63    }
64
65    /// Consume the driver and return the underlying database.
66    pub fn into_db(self) -> ProjectDb {
67        self.db
68    }
69
70    // ── Discovery ────────────────────────────────────────────────────
71
72    /// Discover all files reachable via INCLUDEs from the entry point.
73    pub fn discover<F>(&mut self, entry: &str, read_file: F) -> Result<(), DiscoverError>
74    where
75        F: FnMut(&str) -> Result<String, io::Error>,
76    {
77        discover::discover(&mut self.db, entry, &mut { read_file })
78    }
79
80    /// Discover a native `.brink` project: enumerate `tree` (sorted,
81    /// root-relative keys, scoped to `tree`'s own constructor-held root —
82    /// issue #1371) and load every file — no `INCLUDE` BFS, since native has
83    /// no `INCLUDE`s. `tree` must be constructed with the project's source
84    /// root (`RealFs::new`/`GitRev::new`) — see [`native_source_root`] to
85    /// derive it from an entry path.
86    pub fn discover_native(&mut self, tree: &dyn SourceTree) -> Result<(), DiscoverError> {
87        discover_native::discover_native(&mut self.db, tree)
88    }
89
90    // ── Analysis ─────────────────────────────────────────────────────
91
92    /// Run cross-file analysis on all files (memoized by the db's `analysis`
93    /// query — an unchanged project returns the cached result).
94    pub fn analyze(&mut self) -> &AnalysisResult {
95        self.db.analysis()
96    }
97
98    /// Run analysis on a specific subset of files (one project). Not cached.
99    ///
100    /// Module-aware and options-honoring (issue #1553): the pass runs with
101    /// the db's own [`ProjectDb::module_map`] and registered
102    /// [`AnalysisOptions`], so the `DefinitionId`s it mints key this db's
103    /// per-def queries and the declared dialect/types/lints reach it — the
104    /// same contract [`analyze`](Self::analyze) has. A bare
105    /// `brink_analyzer::analyze`/`analyze_with_options` here was
106    /// module-*blind* and dropped the options entirely, which for a native
107    /// `.brink` project (whose module is its path, always declared) mints a
108    /// different identity space than the db's — see
109    /// [`ProjectDb::module_map`]'s doc.
110    ///
111    /// Stem-collision diagnostics (`E085`) are folded in from
112    /// [`ProjectDb::module_map_diagnostics`], scoped to `file_ids`, for the
113    /// same reason: the analyzer is handed the finished map and cannot
114    /// re-derive them.
115    pub fn analyze_project(&self, file_ids: &[FileId]) -> AnalysisResult {
116        let inputs = self.db.analysis_inputs_for(file_ids);
117        let file_refs: Vec<_> = inputs
118            .iter()
119            .map(|(id, hir, manifest)| (*id, hir, manifest))
120            .collect();
121        let mut result = brink_analyzer::analyze_with_modules(
122            &file_refs,
123            self.db.module_map(),
124            self.db.analysis_options(),
125            // `is_native` (issue #1358): a whole-*set* flag, so it is
126            // answerable for an arbitrary subset even though no single file
127            // is its root — every file native, or the ink arm. Without it
128            // this path judged native source by the ink rules: spurious
129            // `E051`/`E064`, and the B0.9 strict-only gate (`E137`) missing.
130            !file_ids.is_empty() && file_ids.iter().all(|id| self.db.is_native(*id)),
131        );
132        result.diagnostics.extend(
133            self.db
134                .module_map_diagnostics()
135                .iter()
136                .filter(|d| file_ids.contains(&d.file))
137                .cloned(),
138        );
139        result
140    }
141
142    /// Snapshot analysis inputs for a subset of files.
143    pub fn analysis_inputs_for(
144        &self,
145        file_ids: &[FileId],
146    ) -> Vec<(FileId, brink_ir::HirFile, brink_ir::SymbolManifest)> {
147        self.db.analysis_inputs_for(file_ids)
148    }
149
150    /// Snapshot all analysis inputs.
151    pub fn analysis_inputs(&self) -> Vec<(FileId, brink_ir::HirFile, brink_ir::SymbolManifest)> {
152        self.db.analysis_inputs()
153    }
154
155    // ── Project graph ────────────────────────────────────────────────
156
157    /// Compute independent projects: ink files by `INCLUDE` reachability,
158    /// native `.brink` files as one project (issue #1562). See
159    /// [`brink_db::ProjectDb::compute_projects`].
160    pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
161        self.db.compute_projects()
162    }
163
164    /// Return file IDs in topological include order.
165    pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
166        self.db.file_ids_topo(entry)
167    }
168
169    /// Snapshot file metadata for diagnostic publishing.
170    pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
171        self.db.file_metadata()
172    }
173
174    // ── Diagnostics ──────────────────────────────────────────────────
175
176    /// Collect all diagnostics (lowering + analysis), apply suppressions, partition.
177    pub fn collect_diagnostics(
178        &self,
179        analysis: &AnalysisResult,
180        entry: Option<FileId>,
181    ) -> DiagnosticReport {
182        diagnostics::collect_diagnostics(&self.db, analysis, entry)
183    }
184
185    // ── LIR preparation ─────────────────────────────────────────────
186
187    /// Prepare inputs for LIR lowering.
188    ///
189    /// Returns HIR files in topological order and a path map for diagnostics.
190    pub fn lir_inputs(
191        &self,
192        entry: FileId,
193    ) -> (Vec<(FileId, &brink_ir::HirFile)>, HashMap<FileId, String>) {
194        let ids = self.file_ids_topo(entry);
195        let files: Vec<_> = ids
196            .into_iter()
197            .filter_map(|id| self.db.hir(id).map(|hir| (id, hir)))
198            .collect();
199        let paths: HashMap<_, _> = files
200            .iter()
201            .filter_map(|(id, _)| self.db.file_path(*id).map(|p| (*id, p.to_string())))
202            .collect();
203        (files, paths)
204    }
205}
206
207impl Default for Driver {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    /// [`analyze_project`](Driver::analyze_project) must be module-aware: for
218    /// a native `.brink` project the `DefinitionId`s it mints have to key the
219    /// same db per-def queries ([`ProjectDb::effects`],
220    /// [`ProjectDb::signature`]) the db itself is queried by elsewhere —
221    /// otherwise every `analyze_project`-derived id misses on those queries
222    /// (issue #1553). A bare `brink_analyzer::analyze`/`analyze_with_options`
223    /// over the same inputs is module-*blind*: a native file's module is
224    /// path-derived and always declared, so it mints a different identity
225    /// space than the db's own module-aware queries, and this test fails
226    /// against that old code path.
227    #[test]
228    fn analyze_project_ids_key_the_db_per_def_queries_for_native_files() {
229        let mut driver = Driver::new();
230        driver.db_mut().update_file(
231            "market/barter.brink",
232            "flow haggle() {\n  You haggle over the price.\n}\n".to_owned(),
233        );
234        let main = driver.db_mut().update_file(
235            "main.brink",
236            "use story::market::barter::haggle;\n\nflow start() {\n  The market is busy.\n  -> haggle\n}\n"
237                .to_owned(),
238        );
239
240        let ids: Vec<FileId> = driver.db().file_ids().collect();
241        let result = driver.analyze_project(&ids);
242
243        let haggle_ids = result
244            .index
245            .by_name
246            .get("haggle")
247            .expect("`haggle` is declared");
248        assert_eq!(haggle_ids.len(), 1, "exactly one `haggle`");
249        let id = haggle_ids[0];
250
251        assert!(
252            driver.db().effects(id).is_some(),
253            "`db.effects` missed for `haggle` ({id}) — analyze_project minted \
254             an id the db's own queries don't recognize"
255        );
256        assert!(
257            driver.db().signature(id).is_some(),
258            "`db.signature` missed for `haggle` ({id}) — analyze_project minted \
259             an id the db's own queries don't recognize"
260        );
261
262        // Sanity: the other file's flow is reachable too.
263        assert!(result.index.by_name.contains_key("start"));
264        let _ = main;
265    }
266
267    /// [`analyze_project`](Driver::analyze_project) must also judge native
268    /// source by the *native* rules (issue #1358): the flag is a whole-set
269    /// one, so an all-native `file_ids` selects the native arm even though
270    /// the subset has no root to anchor on. Under the ink arm this fixture's
271    /// `struct` declaration and construction literal are rejected as brink
272    /// extensions (`E051`) — asserted here as the non-vacuity guard, so this
273    /// test cannot pass with the wiring removed.
274    #[test]
275    fn analyze_project_judges_an_all_native_subset_by_the_native_rules() {
276        const SRC: &str = "\
277struct Guest {
278  name: string
279}
280
281fn make(): Guest {
282  return Guest { name: \"ada\" };
283}
284
285flow start() {
286  The market is busy.
287  -> END
288}
289";
290        let mut driver = Driver::new();
291        let native = driver.db_mut().update_file("main.brink", SRC.to_owned());
292
293        let result = driver.analyze_project(&[native]);
294        assert!(
295            !result
296                .diagnostics
297                .iter()
298                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
299            "native source must not be judged by the ink-only T1b gate: {:?}",
300            result.diagnostics
301        );
302
303        // The guard: the same inputs through the ink arm do provoke `E051`.
304        let inputs = driver.db().analysis_inputs_for(&[native]);
305        let refs: Vec<_> = inputs.iter().map(|(id, hir, m)| (*id, hir, m)).collect();
306        let ink_arm = brink_analyzer::analyze_with_modules(
307            &refs,
308            driver.db().module_map(),
309            driver.db().analysis_options(),
310            false,
311        );
312        assert!(
313            ink_arm
314                .diagnostics
315                .iter()
316                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
317            "guard: the fixture must provoke `E051` under the ink arm, or \
318             this test proves nothing"
319        );
320    }
321
322    /// A **mixed** subset stays on the ink arm — the flag is whole-set, and
323    /// applying the native arm would judge the ink file by rules it isn't
324    /// written under.
325    #[test]
326    fn analyze_project_falls_back_to_the_ink_rules_for_a_mixed_subset() {
327        let mut driver = Driver::new();
328        let native = driver
329            .db_mut()
330            .update_file("main.brink", "flow start() {\n  Hi.\n}\n".to_owned());
331        let ink = driver
332            .db_mut()
333            .update_file("legacy.ink", "~ x = a[0]\n".to_owned());
334
335        let result = driver.analyze_project(&[native, ink]);
336        assert!(
337            result
338                .diagnostics
339                .iter()
340                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
341            "a mixed subset must stay on the ink arm: {:?}",
342            result.diagnostics
343        );
344    }
345
346    /// [`analyze_project`](Driver::analyze_project) folds in the module map's
347    /// stem-collision diagnostics (`E085`), scoped to the requested
348    /// `file_ids` — not the whole db. `head.ink` declares module `alpha`;
349    /// the separate, undeclared `alpha.ink` has stem `alpha`, which is the
350    /// forbidden footgun the diagnostic exists for. The diagnostic is
351    /// attributed to the undeclared file (`alpha.ink`), so scoping
352    /// `file_ids` to exclude it must also exclude the diagnostic.
353    #[test]
354    fn analyze_project_folds_e085_scoped_to_file_ids() {
355        let mut driver = Driver::new();
356        // `head.ink` declares module `alpha`; `alpha.ink` is a separate,
357        // undeclared file whose *stem* is also `alpha` — the collision.
358        let head = driver
359            .db_mut()
360            .update_file("head.ink", "#@module(alpha)\n== a_knot ==\nHi\n".to_owned());
361        let collider = driver
362            .db_mut()
363            .update_file("alpha.ink", "== other ==\nHi\n".to_owned());
364
365        // Scoped to both files: the collision is folded in.
366        let result_scoped = driver.analyze_project(&[head, collider]);
367        assert!(
368            result_scoped
369                .diagnostics
370                .iter()
371                .any(|d| d.code == brink_ir::DiagnosticCode::E085),
372            "expected E085 stem collision when both files are in scope, got {:?}",
373            result_scoped
374                .diagnostics
375                .iter()
376                .map(|d| d.code)
377                .collect::<Vec<_>>()
378        );
379
380        // Scoped to just `head.ink`: the colliding file is out of scope, so
381        // the diagnostic (which is attributed to `alpha.ink`) must not
382        // appear — pinning the `file_ids.contains(&d.file)` filter.
383        let result_unscoped = driver.analyze_project(&[head]);
384        assert!(
385            !result_unscoped
386                .diagnostics
387                .iter()
388                .any(|d| d.code == brink_ir::DiagnosticCode::E085),
389            "E085 must not appear when the colliding file is excluded from file_ids, got {:?}",
390            result_unscoped
391                .diagnostics
392                .iter()
393                .map(|d| d.code)
394                .collect::<Vec<_>>()
395        );
396    }
397}