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}