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        // Option A total (2026-08-24): the db's member-set-keyed subset
117        // query — the retired `analyze_with_modules` composition relocated
118        // into salsa. Everything this method used to thread by hand is
119        // inside it: the db's module map (#1526), member-filtered
120        // stem-collision diagnostics (#1553), the registered options, and
121        // the all-native-set classification (#1358).
122        self.db.analysis_for_members(file_ids).clone()
123    }
124
125    /// Snapshot analysis inputs for a subset of files.
126    pub fn analysis_inputs_for(
127        &self,
128        file_ids: &[FileId],
129    ) -> Vec<(FileId, brink_ir::HirFile, brink_ir::SymbolManifest)> {
130        self.db.analysis_inputs_for(file_ids)
131    }
132
133    /// Snapshot all analysis inputs.
134    pub fn analysis_inputs(&self) -> Vec<(FileId, brink_ir::HirFile, brink_ir::SymbolManifest)> {
135        self.db.analysis_inputs()
136    }
137
138    // ── Project graph ────────────────────────────────────────────────
139
140    /// Compute independent projects: ink files by `INCLUDE` reachability,
141    /// native `.brink` files as one project (issue #1562). See
142    /// [`brink_db::ProjectDb::compute_projects`].
143    pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
144        self.db.compute_projects()
145    }
146
147    /// Return file IDs in topological include order.
148    pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
149        self.db.file_ids_topo(entry)
150    }
151
152    /// Snapshot file metadata for diagnostic publishing.
153    pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
154        self.db.file_metadata()
155    }
156
157    // ── Diagnostics ──────────────────────────────────────────────────
158
159    /// Collect all diagnostics (lowering + analysis), apply suppressions, partition.
160    pub fn collect_diagnostics(
161        &self,
162        analysis: &AnalysisResult,
163        entry: Option<FileId>,
164    ) -> DiagnosticReport {
165        diagnostics::collect_diagnostics(&self.db, analysis, entry)
166    }
167
168    // ── LIR preparation ─────────────────────────────────────────────
169
170    /// Prepare inputs for LIR lowering.
171    ///
172    /// Returns HIR files in topological order and a path map for diagnostics.
173    pub fn lir_inputs(
174        &self,
175        entry: FileId,
176    ) -> (Vec<(FileId, &brink_ir::HirFile)>, HashMap<FileId, String>) {
177        let ids = self.file_ids_topo(entry);
178        let files: Vec<_> = ids
179            .into_iter()
180            .filter_map(|id| self.db.hir(id).map(|hir| (id, hir)))
181            .collect();
182        let paths: HashMap<_, _> = files
183            .iter()
184            .filter_map(|(id, _)| self.db.file_path(*id).map(|p| (*id, p.to_string())))
185            .collect();
186        (files, paths)
187    }
188}
189
190impl Default for Driver {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// [`analyze_project`](Driver::analyze_project) must be module-aware: for
201    /// a native `.brink` project the `DefinitionId`s it mints have to key the
202    /// same db per-def queries ([`ProjectDb::effects`],
203    /// [`ProjectDb::signature`]) the db itself is queried by elsewhere —
204    /// otherwise every `analyze_project`-derived id misses on those queries
205    /// (issue #1553). A bare `brink_analyzer::analyze`/`analyze_with_options`
206    /// over the same inputs is module-*blind*: a native file's module is
207    /// path-derived and always declared, so it mints a different identity
208    /// space than the db's own module-aware queries, and this test fails
209    /// against that old code path.
210    #[test]
211    fn analyze_project_ids_key_the_db_per_def_queries_for_native_files() {
212        let mut driver = Driver::new();
213        driver.db_mut().update_file(
214            "market/barter.brink",
215            "flow haggle() {\n  You haggle over the price.\n}\n".to_owned(),
216        );
217        let main = driver.db_mut().update_file(
218            "main.brink",
219            "use story::market::barter::haggle;\n\nflow start() {\n  The market is busy.\n  -> haggle\n}\n"
220                .to_owned(),
221        );
222
223        let ids: Vec<FileId> = driver.db().file_ids().collect();
224        let result = driver.analyze_project(&ids);
225
226        let haggle_ids = result
227            .index
228            .by_name
229            .get("haggle")
230            .expect("`haggle` is declared");
231        assert_eq!(haggle_ids.len(), 1, "exactly one `haggle`");
232        let id = haggle_ids[0];
233
234        assert!(
235            driver.db().effects(id).is_some(),
236            "`db.effects` missed for `haggle` ({id}) — analyze_project minted \
237             an id the db's own queries don't recognize"
238        );
239        assert!(
240            driver.db().signature(id).is_some(),
241            "`db.signature` missed for `haggle` ({id}) — analyze_project minted \
242             an id the db's own queries don't recognize"
243        );
244
245        // Sanity: the other file's flow is reachable too.
246        assert!(result.index.by_name.contains_key("start"));
247        let _ = main;
248    }
249
250    /// [`analyze_project`](Driver::analyze_project) must also judge native
251    /// source by the *native* rules (issue #1358): the flag is a whole-set
252    /// one, so an all-native `file_ids` selects the native arm even though
253    /// the subset has no root to anchor on. Under the ink arm this fixture's
254    /// `struct` declaration and construction literal are rejected as brink
255    /// extensions (`E051`) — asserted here as the non-vacuity guard, so this
256    /// test cannot pass with the wiring removed.
257    #[test]
258    fn analyze_project_judges_an_all_native_subset_by_the_native_rules() {
259        const SRC: &str = "\
260struct Guest {
261  name: string
262}
263
264fn make(): Guest {
265  return Guest { name: \"ada\" };
266}
267
268flow start() {
269  The market is busy.
270  -> END
271}
272";
273        let mut driver = Driver::new();
274        let native = driver.db_mut().update_file("main.brink", SRC.to_owned());
275
276        let result = driver.analyze_project(&[native]);
277        assert!(
278            !result
279                .diagnostics
280                .iter()
281                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
282            "native source must not be judged by the ink-only T1b gate: {:?}",
283            result.diagnostics
284        );
285
286        // The guard: the same inputs through the ink arm do provoke `E051`.
287        // `analyze_with_options` is the analyzer's module-blind, ink-arm
288        // test surface (the `analyze_with_modules` monolith retired with
289        // option A total, 2026-08-24); E051 is the per-file dialect gate,
290        // module-independent, so blindness costs the guard nothing.
291        let inputs = driver.db().analysis_inputs_for(&[native]);
292        let refs: Vec<_> = inputs.iter().map(|(id, hir, m)| (*id, hir, m)).collect();
293        let ink_arm = brink_analyzer::analyze_with_options(&refs, driver.db().analysis_options());
294        assert!(
295            ink_arm
296                .diagnostics
297                .iter()
298                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
299            "guard: the fixture must provoke `E051` under the ink arm, or \
300             this test proves nothing"
301        );
302    }
303
304    /// A **mixed** subset stays on the ink arm — the flag is whole-set, and
305    /// applying the native arm would judge the ink file by rules it isn't
306    /// written under.
307    #[test]
308    fn analyze_project_falls_back_to_the_ink_rules_for_a_mixed_subset() {
309        let mut driver = Driver::new();
310        let native = driver
311            .db_mut()
312            .update_file("main.brink", "flow start() {\n  Hi.\n}\n".to_owned());
313        let ink = driver
314            .db_mut()
315            .update_file("legacy.ink", "~ x = a[0]\n".to_owned());
316
317        let result = driver.analyze_project(&[native, ink]);
318        assert!(
319            result
320                .diagnostics
321                .iter()
322                .any(|d| d.code == brink_ir::DiagnosticCode::E051),
323            "a mixed subset must stay on the ink arm: {:?}",
324            result.diagnostics
325        );
326    }
327
328    /// [`analyze_project`](Driver::analyze_project) folds in the module map's
329    /// stem-collision diagnostics (`E085`), scoped to the requested
330    /// `file_ids` — not the whole db. `head.ink` declares module `alpha`;
331    /// the separate, undeclared `alpha.ink` has stem `alpha`, which is the
332    /// forbidden footgun the diagnostic exists for. The diagnostic is
333    /// attributed to the undeclared file (`alpha.ink`), so scoping
334    /// `file_ids` to exclude it must also exclude the diagnostic.
335    #[test]
336    fn analyze_project_folds_e085_scoped_to_file_ids() {
337        let mut driver = Driver::new();
338        // `head.ink` declares module `alpha`; `alpha.ink` is a separate,
339        // undeclared file whose *stem* is also `alpha` — the collision.
340        let head = driver
341            .db_mut()
342            .update_file("head.ink", "#@module(alpha)\n== a_knot ==\nHi\n".to_owned());
343        let collider = driver
344            .db_mut()
345            .update_file("alpha.ink", "== other ==\nHi\n".to_owned());
346
347        // Scoped to both files: the collision is folded in.
348        let result_scoped = driver.analyze_project(&[head, collider]);
349        assert!(
350            result_scoped
351                .diagnostics
352                .iter()
353                .any(|d| d.code == brink_ir::DiagnosticCode::E085),
354            "expected E085 stem collision when both files are in scope, got {:?}",
355            result_scoped
356                .diagnostics
357                .iter()
358                .map(|d| d.code)
359                .collect::<Vec<_>>()
360        );
361
362        // Scoped to just `head.ink`: the colliding file is out of scope, so
363        // the diagnostic (which is attributed to `alpha.ink`) must not
364        // appear — pinning the `file_ids.contains(&d.file)` filter.
365        let result_unscoped = driver.analyze_project(&[head]);
366        assert!(
367            !result_unscoped
368                .diagnostics
369                .iter()
370                .any(|d| d.code == brink_ir::DiagnosticCode::E085),
371            "E085 must not appear when the colliding file is excluded from file_ids, got {:?}",
372            result_unscoped
373                .diagnostics
374                .iter()
375                .map(|d| d.code)
376                .collect::<Vec<_>>()
377        );
378    }
379}