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}