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