brink_db/db.rs
1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use brink_analyzer::{
5 AnalysisOptions, AnalysisResult, BodyTypes, EffectRow, HarvestIndex, HarvestNames,
6 InferenceResult, InferredSig, Sig, SymbolMeta,
7};
8use brink_format::DefinitionId;
9use brink_ir::suppressions::Suppressions;
10use brink_ir::{Diagnostic, FileId, HirFile, ResolutionMap, SymbolIndex, SymbolManifest};
11use brink_syntax::Parse;
12use brink_syntax_native::Parse as NativeParse;
13use salsa::Setter as _;
14use tracing::debug;
15
16use crate::determinism::LookupMap;
17use crate::queries::{
18 BrinkDatabase, CompileProduct, DefKey, KnotChunkKey, LirProduct, ProjectInput, ResolvedProject,
19 SourceFile, analysis_query, call_site_diagnostics_query, call_site_metas_query,
20 conventions_projection_query, diagnostics_query, effects_query, harvest_completion_index_query,
21 harvest_index_query, has_errors_query, include_graph_query, infer_body_query,
22 inferred_signature_query, is_source_file, lir_knot_chunk_query, lir_prelude_decls_query,
23 lir_query, local_signature_query, lowered_query, module_map_query, parse_native_query,
24 parse_query, per_file_diagnostics_query, resolutions_index_query, resolve_query,
25 signature_query, story_data_query, suppressions_query, symbol_index_query,
26 type_diagnostics_query, type_inference_query, ufcs_resolution_query, value_meta_query,
27};
28
29/// Stateful incremental project database.
30///
31/// A thin, path-keyed shell around a [salsa](https://github.com/salsa-rs/salsa)
32/// database: file texts are salsa inputs, and every derived artifact (parse
33/// tree, HIR, symbol index, resolutions, LIR, `StoryData`) is a memoized
34/// tracked query with real dependency tracking and early cutoff. Both the
35/// compiler (one-shot) and LSP/IDE (long-lived) use this as their project
36/// model; editor overlays are plain input writes.
37pub struct ProjectDb {
38 salsa: BrinkDatabase,
39 project: ProjectInput,
40 /// Live files only — every public accessor reads through this map, so
41 /// tombstoned inputs (see `retired`) are invisible to consumers.
42 files: LookupMap<FileId, SourceFile>,
43 path_to_id: LookupMap<String, FileId>,
44 id_to_path: LookupMap<FileId, String>,
45 /// Tombstoned salsa inputs from removed files, keyed by path — the
46 /// durable path→`FileId` identity store (#536). Salsa never forgets an
47 /// input, so [`remove_file`](Self::remove_file) parks the `SourceFile`
48 /// here (text cleared) instead of dropping the handle; re-adding the
49 /// same path reinstates it, reusing its original `FileId` so the old
50 /// per-file memos are overwritten in place rather than leaking as
51 /// permanently unreachable dead entries (rust-analyzer precedent).
52 retired: LookupMap<String, SourceFile>,
53 next_id: u32,
54}
55
56impl ProjectDb {
57 /// Create an empty project database.
58 pub fn new() -> Self {
59 Self::with_id_base(0)
60 }
61
62 /// Create an empty project database whose `FileId`s start counting from
63 /// `id_base` instead of `0` (issue #1580).
64 ///
65 /// A long-lived host that keeps *multiple* independent `ProjectDb`
66 /// instances alive at once — `brink-lsp`'s per-native-project extent
67 /// partitioning, one db per governing `brink.toml` — needs every
68 /// instance's `FileId`s to be mutually disjoint: each db mints its own
69 /// `FileId`s starting at `0` internally, so two dbs each holding a
70 /// first-registered file would otherwise both mint `FileId(0)`, and a
71 /// caller merging per-project data into one `FileId`-keyed map (as
72 /// `brink-lsp`'s cross-project `ProjectAnalyses` does) would silently
73 /// conflate two unrelated files. Callers are responsible for choosing
74 /// non-overlapping `id_base` ranges (e.g. a fixed stride per project
75 /// index) — this constructor only seeds the counter, it does not police
76 /// collisions across instances it knows nothing about.
77 pub fn with_id_base(id_base: u32) -> Self {
78 let salsa = BrinkDatabase::default();
79 let project = ProjectInput::new(
80 &salsa,
81 Vec::new(),
82 None,
83 AnalysisOptions::default(),
84 None,
85 None,
86 );
87 Self {
88 salsa,
89 project,
90 files: LookupMap::new(),
91 path_to_id: LookupMap::new(),
92 id_to_path: LookupMap::new(),
93 retired: LookupMap::new(),
94 next_id: id_base,
95 }
96 }
97
98 /// Add or replace a file. An existing file's text is overwritten in
99 /// place (an input write); derived queries recompute lazily on next read.
100 ///
101 /// Path→`FileId` identity is durable (#536): re-adding a path that was
102 /// previously [`remove_file`](Self::remove_file)d reinstates its original
103 /// `FileId` and salsa input, so per-file memos are overwritten in place
104 /// instead of accumulating under freshly-minted dead ids.
105 pub fn set_file(&mut self, path: &str, source: String) -> FileId {
106 if let Some(&id) = self.path_to_id.get(path) {
107 if let Some(&file) = self.files.get(&id) {
108 file.set_text(&mut self.salsa).to(source);
109 }
110 debug!(path, id = id.0, "set_file complete");
111 return id;
112 }
113
114 // Reinstate a tombstoned input if this path existed before,
115 // otherwise mint a fresh id + input.
116 let file = if let Some(file) = self.retired.remove(path) {
117 file.set_text(&mut self.salsa).to(source);
118 file
119 } else {
120 let id = FileId(self.next_id);
121 self.next_id += 1;
122 SourceFile::new(&self.salsa, id, path.to_string(), source)
123 };
124 let id = file.file_id(&self.salsa);
125 self.path_to_id.insert(path.to_string(), id);
126 self.id_to_path.insert(id, path.to_string());
127 self.files.insert(id, file);
128
129 // A reinstated `FileId` can be smaller than later-minted ids, so a
130 // plain push would break the list's `FileId` ordering — insert at
131 // the sorted position instead.
132 let mut list = self.project.files(&self.salsa).clone();
133 let pos = list.partition_point(|f| f.file_id(&self.salsa).0 < id.0);
134 list.insert(pos, file);
135 self.project.set_files(&mut self.salsa).to(list);
136
137 debug!(path, id = id.0, "set_file complete");
138 id
139 }
140
141 /// Incrementally update a file. Identical to [`set_file`](Self::set_file):
142 /// salsa's dependency tracking decides what recomputes.
143 pub fn update_file(&mut self, path: &str, source: String) -> FileId {
144 self.set_file(path, source)
145 }
146
147 /// Remove a file from the database.
148 ///
149 /// The salsa input is tombstoned, not forgotten (#536): salsa can never
150 /// reclaim an input or the memos keyed on it, so the `SourceFile` is
151 /// parked in `retired` with its text cleared (releasing the source and
152 /// invalidating stale derived memos) while dropping out of the project
153 /// file list and every path/id map. From a consumer's view the file is
154 /// gone — enumeration, lookups, and INCLUDE resolution behave exactly as
155 /// if it never existed; re-adding the path reuses its original `FileId`.
156 pub fn remove_file(&mut self, path: &str) {
157 if let Some(id) = self.path_to_id.remove(path) {
158 self.id_to_path.remove(&id);
159 if let Some(file) = self.files.remove(&id) {
160 file.set_text(&mut self.salsa).to(String::new());
161 self.retired.insert(path.to_string(), file);
162 let list: Vec<SourceFile> = self
163 .project
164 .files(&self.salsa)
165 .iter()
166 .copied()
167 .filter(|f| f.file_id(&self.salsa) != id)
168 .collect();
169 self.project.set_files(&mut self.salsa).to(list);
170 }
171 if self.project.entry(&self.salsa) == Some(id) {
172 self.project.set_entry(&mut self.salsa).to(None);
173 }
174 }
175 }
176
177 /// Set the compile entry point (for the [`lir_product`](Self::lir_product)
178 /// and [`story_data`](Self::story_data) queries). The file must already
179 /// be in the database.
180 pub fn set_entry(&mut self, path: &str) -> Option<FileId> {
181 let id = self.file_id(path)?;
182 if self.project.entry(&self.salsa) != Some(id) {
183 self.project.set_entry(&mut self.salsa).to(Some(id));
184 }
185 Some(id)
186 }
187
188 /// The current compile entry point, if any.
189 pub fn entry(&self) -> Option<FileId> {
190 self.project.entry(&self.salsa)
191 }
192
193 /// Set the analysis options (host manifest + external-check severity)
194 /// used by the [`analysis`](Self::analysis) and downstream queries.
195 pub fn set_analysis_options(&mut self, options: AnalysisOptions) {
196 self.project
197 .set_analysis_options(&mut self.salsa)
198 .to(options);
199 }
200
201 /// The analysis options currently registered with the database.
202 pub fn analysis_options(&self) -> &AnalysisOptions {
203 self.project.analysis_options(&self.salsa)
204 }
205
206 /// Register the directory native `.brink` file keys are root-relative
207 /// *to* (issue #1572).
208 ///
209 /// A native file's module — and therefore every `DefinitionId` it
210 /// qualifies — is a pure function of its **root-relative** key
211 /// (decision-log 2026-07-22 "Native module identity"). `brink-driver`'s
212 /// `discover_native` already registers such keys, so a compile leaves
213 /// this `None` and nothing changes. A consumer that must key by some
214 /// other prefix — the LSP keys by absolute OS path, because every path it
215 /// holds round-trips through a `file://` URI — declares that prefix here,
216 /// and the identity it mints then matches a real compile of the same
217 /// tree byte for byte instead of embedding the machine's directory
218 /// layout. Paths not under `root` are unaffected.
219 ///
220 /// Ink (`.ink`) files never consult this: their module is their file
221 /// *stem*, which no path prefix can change.
222 pub fn set_native_root(&mut self, root: Option<String>) {
223 if self.project.native_root(&self.salsa) != &root {
224 self.project.set_native_root(&mut self.salsa).to(root);
225 }
226 }
227
228 /// The registered native source root, if any — see
229 /// [`set_native_root`](Self::set_native_root).
230 pub fn native_root(&self) -> Option<&str> {
231 self.project.native_root(&self.salsa).as_deref()
232 }
233
234 /// Register the directory `.ink` file keys are root-relative *to*
235 /// (issue #1696) — ink's sibling of [`set_native_root`](Self::set_native_root),
236 /// consulted by [`hir::root_content_scope_path`](brink_ir::hir::root_content_scope_path)'s
237 /// qualifier rather than by module identity.
238 ///
239 /// `brink-compiler/src/driver.rs`'s `prepare_driver` registers this for
240 /// every ink compile, using `brink_driver::native_source_root` (the same
241 /// root-discovery rule native compiles already use) fed the entry path.
242 /// `None` — no caller has registered a root — is byte-identical to the
243 /// pre-#1696 world: the qualifier stays the file's raw registered path.
244 pub fn set_ink_root(&mut self, root: Option<String>) {
245 if self.project.ink_root(&self.salsa) != &root {
246 self.project.set_ink_root(&mut self.salsa).to(root);
247 }
248 }
249
250 /// The registered ink source root, if any — see
251 /// [`set_ink_root`](Self::set_ink_root).
252 pub fn ink_root(&self) -> Option<&str> {
253 self.project.ink_root(&self.salsa).as_deref()
254 }
255
256 /// Look up a file's ID by path.
257 pub fn file_id(&self, path: &str) -> Option<FileId> {
258 self.path_to_id.get(path).copied()
259 }
260
261 /// Look up a file's path by ID.
262 pub fn file_path(&self, id: FileId) -> Option<&str> {
263 self.id_to_path.get(&id).map(String::as_str)
264 }
265
266 /// Iterate over all registered file IDs.
267 pub fn file_ids(&self) -> impl Iterator<Item = FileId> + '_ {
268 let mut ids: Vec<_> = self.files.keys().copied().collect();
269 ids.sort_by_key(|id| id.0);
270 ids.into_iter()
271 }
272
273 /// Return file IDs in topological include order (included files before
274 /// the files that include them), matching ink's `INCLUDE` paste
275 /// semantics. Only `entry` and files it transitively `INCLUDE`s are
276 /// returned — see [`IncludeGraph::topological_order`] (issue #815).
277 pub fn file_ids_topo(&self, entry: FileId) -> Vec<FileId> {
278 self.include_graph().topological_order(entry)
279 }
280
281 /// Get the parse tree for a file.
282 pub fn parse(&self, id: FileId) -> Option<&Parse> {
283 let file = self.files.get(&id)?;
284 Some(parse_query(&self.salsa, *file))
285 }
286
287 /// Get the native (`.brink`) parse tree for a file (B0.10a, the native
288 /// compile seam, issue #1106). The native-frontend sibling of
289 /// [`parse`](Self::parse) — a distinct nominal `Parse` type. This runs the
290 /// native parser regardless of the file's extension; the extension-based
291 /// frontend dispatch that decides which parser *lowering* uses lives in
292 /// `lowered_query`, so `parse()` stays ink-typed and untouched for the
293 /// LSP/IDE ink path.
294 pub fn parse_native(&self, id: FileId) -> Option<&NativeParse> {
295 let file = self.files.get(&id)?;
296 Some(parse_native_query(&self.salsa, *file))
297 }
298
299 /// Get the HIR for a file. `None` for an unknown file id, or for a
300 /// tracked file [`is_source_file`] excludes (issue #2329 review
301 /// finding): this per-file accessor reads `lowered_query` directly, so
302 /// without this gate a `brink.toml`/`.md`/`.json` document would still
303 /// return its bogus ink-lowered HIR.
304 pub fn hir(&self, id: FileId) -> Option<&HirFile> {
305 let file = self.files.get(&id)?;
306 if !is_source_file(file.path(&self.salsa)) {
307 return None;
308 }
309 Some(&lowered_query(&self.salsa, self.project, *file).hir)
310 }
311
312 /// Get the symbol manifest for a file. `None` for an unknown file id, or
313 /// for a tracked file [`is_source_file`] excludes — see [`Self::hir`]'s
314 /// doc (issue #2329 review finding).
315 pub fn manifest(&self, id: FileId) -> Option<&SymbolManifest> {
316 let file = self.files.get(&id)?;
317 if !is_source_file(file.path(&self.salsa)) {
318 return None;
319 }
320 Some(&lowered_query(&self.salsa, self.project, *file).manifest)
321 }
322
323 /// Get the source text for a file.
324 pub fn source(&self, id: FileId) -> Option<&str> {
325 let file = self.files.get(&id)?;
326 Some(file.text(&self.salsa).as_str())
327 }
328
329 /// Get per-file diagnostics (parse + lowering). `None` for an unknown
330 /// file id, or for a tracked file [`is_source_file`] excludes — see
331 /// [`Self::hir`]'s doc (issue #2329 review finding).
332 pub fn file_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
333 let file = self.files.get(&id)?;
334 if !is_source_file(file.path(&self.salsa)) {
335 return None;
336 }
337 Some(
338 lowered_query(&self.salsa, self.project, *file)
339 .diagnostics
340 .as_slice(),
341 )
342 }
343
344 /// Get the B0.3 HIR admission validator's output for a file
345 /// (docs/hir-admission-contract.md §4.2, issue #1172) — kept separate
346 /// from [`Self::file_diagnostics`] because it is non-suppressible
347 /// (never routed through `apply_suppressions`). `None` for an unknown
348 /// file id, or for a tracked file [`is_source_file`] excludes — see
349 /// [`Self::hir`]'s doc (issue #2329 review finding).
350 pub fn admission_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
351 let file = self.files.get(&id)?;
352 if !is_source_file(file.path(&self.salsa)) {
353 return None;
354 }
355 Some(
356 lowered_query(&self.salsa, self.project, *file)
357 .admission
358 .as_slice(),
359 )
360 }
361
362 /// Get suppression directives for a file — the text-scanned
363 /// `brink-disable`/`brink-expect` comments merged with the file's
364 /// HIR-derived `@[allow(…)]` scopes (issue #1161), i.e. parsed ∪
365 /// HIR-derived, not parsed alone.
366 pub fn suppressions(&self, id: FileId) -> Option<&Suppressions> {
367 let file = self.files.get(&id)?;
368 Some(suppressions_query(&self.salsa, *file))
369 }
370
371 /// Rebuild include graph edges for all files.
372 ///
373 /// No-op since the salsa migration: the include graph is a tracked query
374 /// over the full file set and is always complete. Kept so batch-loading
375 /// call sites need no change.
376 pub fn rebuild_include_graph(&mut self) {}
377
378 /// Detect cycles in the include graph.
379 ///
380 /// Returns the first cycle found as an ordered path of file IDs.
381 pub fn find_cycle(&self) -> Option<Vec<FileId>> {
382 self.include_graph().find_cycle()
383 }
384
385 /// Compute independent projects — the unit every editor surface scopes
386 /// itself to (the LSP analyzes one project at a time, and navigation only
387 /// ever sees the files of the project the cursor's file belongs to).
388 ///
389 /// Returns `(root, members)` pairs sorted by root `FileId`; each
390 /// project's members are sorted by `FileId`.
391 ///
392 /// One rule per frontend:
393 ///
394 /// - **Ink** groups by `INCLUDE` reachability — a root file plus its
395 /// transitive `INCLUDE` closure (see
396 /// [`IncludeGraph::compute_projects`](crate::include_graph::IncludeGraph::compute_projects)).
397 /// Unchanged.
398 /// - **Native `.brink`** files are *one* project, all of them. Issue
399 /// #1562: `.brink` has no `INCLUDE` (the module system replaced it), so
400 /// running them through the ink rule made every native file its own
401 /// single-file project and broke go-to-definition, find-references,
402 /// completion, and diagnostics across every real native workspace. The
403 /// rule here is the one
404 /// [`compilation_closure_files`](crate::queries::compilation_closure_files)
405 /// already applies to codegen (decision-log *"Native multi-file
406 /// linking"*, 2026-07-23): the discovered module set **is** the
407 /// compilation unit, so it is also the editor's scope. No second
408 /// discovery mechanism is involved — this partitions the files the db
409 /// already holds.
410 ///
411 /// The two sets are disjoint, so an `INCLUDE` in an ink file that names a
412 /// `.brink` target (not expressible in native, and meaningless as ink)
413 /// contributes no edge: the native file is in the native project only.
414 pub fn compute_projects(&self) -> Vec<(FileId, Vec<FileId>)> {
415 let (native, ink): (Vec<FileId>, Vec<FileId>) =
416 self.file_ids().partition(|&id| self.is_native(id));
417
418 let mut projects = self.include_graph().compute_projects(&ink);
419 if let Some(root) = self.native_project_root(&native) {
420 projects.push((root, native));
421 }
422 projects.sort_by_key(|(root, _)| root.0);
423 projects
424 }
425
426 /// Whether `id` is a native (`.brink`) module rather than an ink file.
427 ///
428 /// `pub` (issue #1562 review finding) so a caller running the off-db
429 /// `brink_analyzer::analyze_with_modules` pass per project root —
430 /// `brink-lsp`'s `analysis_loop`, which needs the same "does this
431 /// project's dialect axis even apply" answer
432 /// [`crate::queries::project_is_native`] gives the salsa-backed
433 /// `symbol_index_query` — can ask it of a project's root `FileId`
434 /// without rederiving [`crate::queries::file_language`] itself.
435 pub fn is_native(&self, id: FileId) -> bool {
436 self.file_path(id).is_some_and(|path| {
437 crate::queries::file_language(path) == crate::queries::Language::Native
438 })
439 }
440
441 /// Whether **every recognized source file** (`.ink` or `.brink`) this db
442 /// holds is a native (`.brink`) module — `false` for an empty db, one
443 /// holding even a single ink source file, or one whose tracked files are
444 /// all non-source documents. A tracked file with neither extension (a
445 /// project's own `brink.toml`, e.g. — issue #2318) does not count either
446 /// way; see [`crate::queries::project_is_all_native`]'s doc for the full
447 /// reasoning and the bug this exemption fixes.
448 ///
449 /// The whole-db view of [`crate::queries::project_is_all_native`], for a
450 /// caller that analyzes this db's entire file set as one unit off-db
451 /// (`IdeSession`, whose editor analysis runs
452 /// [`brink_analyzer::analyze_with_modules`] over
453 /// [`analysis_inputs`](Self::analysis_inputs)). That flag is
454 /// whole-project, so it is only correct when the set is *entirely*
455 /// native: a mixed set must analyze as ink, or an ink file would get the
456 /// native arm of passes that would then mis-judge it.
457 ///
458 /// Distinct from [`is_native`](Self::is_native), which answers for one
459 /// file and is what a per-project caller (`brink-lsp`'s `analysis_loop`,
460 /// which analyzes each project root separately) asks of its root.
461 pub fn is_all_native(&self) -> bool {
462 crate::queries::project_is_all_native(&self.salsa, self.project)
463 }
464
465 /// The root of the single native project: the file whose **path** sorts
466 /// first (`FileId` breaking a tie that paths cannot actually produce).
467 /// `None` when the db holds no native file.
468 ///
469 /// Keyed on the path rather than on the `FileId` — which is how
470 /// [`compilation_closure_files`](crate::queries::compilation_closure_files)
471 /// orders the same file set — because a project root is *identity*, not
472 /// just order: it keys the published per-project analysis and names the
473 /// project in multi-project diagnostics. `FileId`s are minted in
474 /// registration order, which for a long-lived LSP session is `didOpen`
475 /// order and varies run to run; the path does not.
476 fn native_project_root(&self, native: &[FileId]) -> Option<FileId> {
477 native.iter().copied().min_by(|&a, &b| {
478 self.file_path(a)
479 .unwrap_or_default()
480 .cmp(self.file_path(b).unwrap_or_default())
481 .then(a.0.cmp(&b.0))
482 })
483 }
484
485 /// All files reachable from `entry` via the forward `INCLUDE` graph,
486 /// `entry` included.
487 ///
488 /// A forward DFS over `INCLUDE` edges (transitive). The result is a
489 /// [`BTreeSet`], so iteration order is deterministic regardless of graph
490 /// internals — callers that compare or render the set get stable output.
491 pub fn reachable_from(&self, entry: FileId) -> BTreeSet<FileId> {
492 self.include_graph().reachable_from(entry)
493 }
494
495 /// Snapshot analysis inputs for a subset of files.
496 ///
497 /// Like `analysis_inputs()` but filtered to the given set.
498 pub fn analysis_inputs_for(
499 &self,
500 file_ids: &[FileId],
501 ) -> Vec<(FileId, HirFile, SymbolManifest)> {
502 let mut inputs: Vec<_> = file_ids
503 .iter()
504 .filter_map(|&id| {
505 let file = self.files.get(&id)?;
506 let lowered = lowered_query(&self.salsa, self.project, *file);
507 Some((id, lowered.hir.clone(), lowered.manifest.clone()))
508 })
509 .collect();
510 inputs.sort_by_key(|(id, _, _)| id.0);
511 inputs
512 }
513
514 /// Snapshot all analysis inputs for background analysis.
515 ///
516 /// Returns `(FileId, HirFile, SymbolManifest)` tuples cloned out of the db,
517 /// so the caller can run `brink_analyzer::analyze_with_modules` (with
518 /// [`module_map`](Self::module_map), also snapshotted) without holding
519 /// the lock. Issue #1526: a bare `brink_analyzer::analyze()` /
520 /// `analyze_with_options` over these inputs is module-*blind* and mints
521 /// different `DefinitionId`s than this db's own queries for native
522 /// `.brink` files — see [`module_map`](Self::module_map)'s doc.
523 pub fn analysis_inputs(&self) -> Vec<(FileId, HirFile, SymbolManifest)> {
524 let ids: Vec<_> = self.file_ids().collect();
525 self.analysis_inputs_for(&ids)
526 }
527
528 /// Snapshot file metadata for diagnostic publishing.
529 ///
530 /// Returns `(FileId, path, source)` tuples for all files in the db.
531 pub fn file_metadata(&self) -> Vec<(FileId, String, String)> {
532 let mut meta: Vec<_> = self
533 .files
534 .iter()
535 .filter_map(|(&id, file)| {
536 let path = self.id_to_path.get(&id)?.clone();
537 Some((id, path, file.text(&self.salsa).clone()))
538 })
539 .collect();
540 meta.sort_by_key(|(id, _, _)| id.0);
541 meta
542 }
543
544 // ── Query surface (scripting-substrate spec §4) ──────────────────
545
546 /// The merged project-wide symbol index (layer 2, `symbol_index()`).
547 pub fn symbol_index(&self) -> Arc<SymbolIndex> {
548 Arc::clone(&symbol_index_query(&self.salsa, self.project).0)
549 }
550
551 /// Indexing diagnostics (duplicate definitions, built-in shadowing)
552 /// produced alongside [`symbol_index`](Self::symbol_index).
553 pub fn symbol_index_diagnostics(&self) -> &[Diagnostic] {
554 &symbol_index_query(&self.salsa, self.project).1
555 }
556
557 /// The project-wide harvest index (layer 2, issue #2114,
558 /// `docs/prose-dialect-spec.md` §5): every `@NAME` cue payload and every
559 /// inline-markup span kind/attribute name written anywhere in the
560 /// project, upgraded by the registered host manifest's `markup`
561 /// vocabulary where one is declared. The compiler-side sibling of
562 /// [`symbol_index`](Self::symbol_index) — a completion consumer reads
563 /// this the same way it reads that index, and gets the same per-file
564 /// [`lowered_query`] early cutoff the symbol index has: an edit
565 /// backdates this memo when it backdates the symbol index's
566 /// `lowered_query` half, but this index also depends on the registered
567 /// host manifest, so a manifest-only edit backdates this memo without
568 /// touching the symbol index at all (see
569 /// [`harvest_index_query`](crate::queries::harvest_index_query)'s own
570 /// doc for the full dependency set).
571 pub fn harvest_index(&self) -> Arc<HarvestIndex> {
572 Arc::clone(harvest_index_query(&self.salsa, self.project))
573 }
574
575 /// The harvest index's range-free completion projection (issue #2134):
576 /// every harvested cue and span/attribute *name*, with every site's
577 /// `TextRange` dropped. This is the query a keystroke-driven completion
578 /// path should read instead of [`harvest_index`](Self::harvest_index)
579 /// itself — see [`harvest_completion_index_query`]'s own doc for why
580 /// the raw index can never `Eq`-cutoff.
581 pub fn harvest_completion_names(&self) -> Arc<HarvestNames> {
582 Arc::clone(harvest_completion_index_query(&self.salsa, self.project))
583 }
584
585 /// The conventions projection (issue #2111, NS-T seam 1/6): every
586 /// `@[convention]` handler declared in the project's one configured
587 /// conventions module, ascending by `order` — "THE SOLE EDITOR
588 /// INTERCHANGE" the design-backport comment on #2111 names
589 /// (`docs/decision-log.md` 2026-08-03). Reads the `[project] conventions`
590 /// pointer, the project module map, the resolved conventions module's
591 /// transitive `IMPORT` closure (`import_closure_query`, issue #2111
592 /// finding 3), and every file in that closure's own `lowered_query`
593 /// output — see `conventions_projection_query`'s doc for the exact
594 /// dependency set, and `brink_ir::ConventionsProjection`'s doc for the
595 /// one part of #2111 this still does not deliver: it is not yet
596 /// serialized into `.inkb`/`StoryData` (the attach schema IS now
597 /// resolved to its fields and types, not merely a struct name — that
598 /// gap closed in the 2026-08-04 continuation).
599 pub fn conventions_projection(&self) -> Arc<brink_ir::ConventionsProjection> {
600 Arc::clone(conventions_projection_query(&self.salsa, self.project))
601 }
602
603 /// Every file's resolved module (M-1, docs/modules-spec.md §1/§5) — the
604 /// map that qualifies `DefinitionId` identity, built here from file
605 /// stems, `#@module` declarations, the INCLUDE graph, and (for native
606 /// `.brink` files) the path-derived `story::…` module.
607 ///
608 /// Exposed (issue #1526) for callers that must run
609 /// [`brink_analyzer::analyze_with_modules`] *outside* the db — the LSP's
610 /// background analysis pass and [`analysis_inputs`](Self::analysis_inputs)
611 /// consumers generally — so their `DefinitionId`s match the ones this
612 /// db's per-def queries ([`effects`](Self::effects),
613 /// [`signature`](Self::signature), [`infer_body`](Self::infer_body)) are
614 /// keyed by. Identity is minted here and nowhere else.
615 ///
616 /// The map's *diagnostics* half is
617 /// [`module_map_diagnostics`](Self::module_map_diagnostics) — an
618 /// off-db `analyze_with_modules` pass has to fold it back in itself
619 /// (issue #1553).
620 pub fn module_map(&self) -> &brink_analyzer::ModuleMap {
621 &module_map_query(&self.salsa, self.project).0
622 }
623
624 /// Stem-collision diagnostics (`E085`) produced alongside
625 /// [`module_map`](Self::module_map): a file with no `#@module` whose
626 /// stem is some *other* file's declared module name.
627 ///
628 /// A db-driven compile picks these up through
629 /// [`symbol_index_diagnostics`](Self::symbol_index_diagnostics), which
630 /// folds them in. A caller that instead runs
631 /// [`brink_analyzer::analyze_with_modules`] outside the db (the LSP's
632 /// background pass, `IdeSession`'s editor analysis) gets only the
633 /// analyzer's own diagnostics, so before issue #1553 the collision was
634 /// silently dropped on every editor surface. Such callers must snapshot
635 /// this alongside [`module_map`](Self::module_map) and extend their
636 /// result with the entries belonging to their file set.
637 pub fn module_map_diagnostics(&self) -> &[Diagnostic] {
638 &module_map_query(&self.salsa, self.project).1
639 }
640
641 /// One file's resolved references + resolution diagnostics (layer 2,
642 /// `resolve(FileId)`).
643 pub fn resolve(&self, id: FileId) -> Option<(Arc<ResolutionMap>, &[Diagnostic])> {
644 let file = self.files.get(&id)?;
645 let (map, diags) = resolve_query(&self.salsa, self.project, *file);
646 Some((Arc::clone(map), diags.as_slice()))
647 }
648
649 /// Per-declaration signature stub (layer 2, `signature(def)`). `None`
650 /// for an unknown definition id.
651 pub fn signature(&self, def: DefinitionId) -> Option<Arc<Sig>> {
652 signature_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
653 }
654
655 /// Signature stub for a **local** (`Param`/`Temp`) `def`, declared in
656 /// `id` (issue #530): the per-file locals path [`signature`](Self::signature)
657 /// itself can't take — see `local_signature_query`'s doc for why a
658 /// local's `DefinitionId` needs a caller-supplied file. `None` for an
659 /// unknown file id or a `def` not declared as a local in that file
660 /// (including a declaration id — those stay [`signature`](Self::signature)'s
661 /// job).
662 pub fn local_signature(&self, id: FileId, def: DefinitionId) -> Option<Arc<Sig>> {
663 let file = *self.files.get(&id)?;
664 local_signature_query(
665 &self.salsa,
666 self.project,
667 file,
668 DefKey::new(&self.salsa, def),
669 )
670 }
671
672 /// Full cross-file analysis over all files, honoring the registered
673 /// [`AnalysisOptions`]. Memoized; module-aware — identical to
674 /// `brink_analyzer::analyze_with_modules` over
675 /// [`analysis_inputs`](Self::analysis_inputs) and
676 /// [`module_map`](Self::module_map) by construction. For native
677 /// `.brink` files this is *not* identical to `analyze_with_options`
678 /// (module-blind), which mints different `DefinitionId`s — see
679 /// [`module_map`](Self::module_map)'s doc (issue #1526).
680 pub fn analysis(&self) -> &AnalysisResult {
681 analysis_query(&self.salsa, self.project)
682 }
683
684 /// Index + resolutions, no diagnostics (issue #632 / FG-3 — the
685 /// RESOLUTIONS/INDEX half of [`analysis`](Self::analysis), split off
686 /// from the diagnostics half so a diagnostics-only `AnalysisOptions`
687 /// edit leaves this `Arc`'s pointer identity untouched).
688 pub fn resolutions_index(&self) -> Arc<ResolvedProject> {
689 resolutions_index_query(&self.salsa, self.project)
690 }
691
692 /// One file's per-file diagnostic contributors — structural validation,
693 /// the dialect gate, and (brink dialect only) annotation-content checks
694 /// (issue #632 / FG-3). `None` for an unknown file id. A body edit in a
695 /// *different* file leaves this `Arc`'s pointer identity untouched.
696 pub fn per_file_diagnostics(&self, id: FileId) -> Option<Arc<Vec<Diagnostic>>> {
697 let file = *self.files.get(&id)?;
698 Some(per_file_diagnostics_query(&self.salsa, self.project, file))
699 }
700
701 /// One file's VAR/CONST/LIST initializer/doc enrichment (issue #750 /
702 /// FG-3 completion) — purely presentational `symbol_meta` entries, no
703 /// diagnostics. `None` for an unknown file id. A body edit in a
704 /// *different* file leaves this `Arc`'s pointer identity untouched.
705 pub fn file_value_meta(&self, id: FileId) -> Option<Arc<BTreeMap<DefinitionId, SymbolMeta>>> {
706 let file = *self.files.get(&id)?;
707 Some(value_meta_query(&self.salsa, self.project, file))
708 }
709
710 /// One file's external call-site literal checks (`E041`/`E042`, issue
711 /// #750 / FG-3 completion). `None` for an unknown file id; empty when
712 /// the `external_check` severity is `Off`. A body edit in a *different*
713 /// file leaves this `Arc`'s pointer identity untouched.
714 pub fn file_call_site_diagnostics(&self, id: FileId) -> Option<Arc<Vec<Diagnostic>>> {
715 let file = *self.files.get(&id)?;
716 Some(call_site_diagnostics_query(&self.salsa, self.project, file))
717 }
718
719 /// The range-free, name-keyed external metas feeding the per-file
720 /// call-site checks (issue #750 / FG-3 completion) — the cutoff seam
721 /// between the (often re-executed, full-ranged-index-reading)
722 /// enrichment pass and every file's call-site memo. Exposed for the
723 /// dependency-edge tests; pointer identity across an edit proves the
724 /// seam backdated.
725 pub fn call_site_metas(&self) -> Arc<BTreeMap<String, SymbolMeta>> {
726 call_site_metas_query(&self.salsa, self.project)
727 }
728
729 /// Per-file diagnostics including this file's share of analysis
730 /// diagnostics (layer 3, `diagnostics(FileId)`). Raw — no suppression
731 /// filtering.
732 pub fn diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
733 let file = self.files.get(&id)?;
734 Some(diagnostics_query(&self.salsa, self.project, *file).as_slice())
735 }
736
737 /// Whole-project type inference (TM-1, typed-mode-spec §2/§9 step 1).
738 /// Advisory-only substrate: `infer_body`/`type_diagnostics` are thin
739 /// per-def/per-file views over this. Lazy — nothing in `story_data`,
740 /// `lir_product`, or `diagnostics` reads it, so calling this (directly
741 /// or via `infer_body`/`type_diagnostics`) is the only thing that
742 /// triggers the underlying computation.
743 pub fn type_inference(&self) -> &InferenceResult {
744 type_inference_query(&self.salsa, self.project)
745 }
746
747 /// Per-def inferred body types (`infer_body(def)`). `None` for a def
748 /// with no inferable body (not a knot/stitch, or an unknown id).
749 pub fn infer_body(&self, def: DefinitionId) -> Option<Arc<BodyTypes>> {
750 infer_body_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
751 }
752
753 /// Per-def inferred signature (`inferred_signature(def)`, FG-2 issue
754 /// #631) — the firewall-facing per-def view: params + return type only,
755 /// no locals, no ranges. This is the boundary TM-2's annotation-override
756 /// consumer reads. `None` for a def with no inferable body (not a
757 /// knot/stitch, or an unknown id) — same `None` contract as
758 /// [`signature`](Self::signature)/[`infer_body`](Self::infer_body).
759 pub fn inferred_signature(&self, def: DefinitionId) -> Option<Arc<InferredSig>> {
760 inferred_signature_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
761 }
762
763 /// Per-def effect row (`effects(def)`, T2-1, docs/effects-spec.md §2/§4,
764 /// issue #860) — the advisory `{reads, writes, calls}` summary of the
765 /// atomic effects `def` (and everything it transitively calls) may
766 /// perform, sited beside [`inferred_signature`](Self::inferred_signature).
767 /// Conservative-total (spec §3): the row over-reports, never under-reports;
768 /// a call through a function value or an unknown callee makes it pessimal
769 /// ([`EffectRow::opaque`]). `None` for a def with no inferable body (not a
770 /// knot/stitch, or an unknown id) — same contract as
771 /// [`inferred_signature`](Self::inferred_signature).
772 ///
773 /// **Advisory-only**: nothing in `story_data`/`lir_product`/`diagnostics`
774 /// reads this, so the row is additive metadata that leaves compiled output
775 /// byte-identical. Lazy — calling this is the only thing that triggers the
776 /// underlying atom harvest + per-SCC fixpoint.
777 pub fn effects(&self, def: DefinitionId) -> Option<Arc<EffectRow>> {
778 effects_query(&self.salsa, self.project, DefKey::new(&self.salsa, def))
779 }
780
781 /// The B3a UFCS resolution verdict for the call site at `range` in
782 /// `file` (issue #1507) — reads the same memoized `ufcs_resolution_query`
783 /// (#1506) LIR lowering already shares, rather than re-running the
784 /// analyzer's `ufcs` pass a second time for IDE hover/go-to-def. `None`
785 /// when the pass recorded no verdict at this exact range: not a
786 /// UFCS-shaped call site, an unresolved one (already diagnosed
787 /// E140–E143 elsewhere), or the project has no dotted-callee call
788 /// anywhere (`ufcs_resolution_query`'s own laziness gate).
789 pub fn ufcs_verdict(
790 &self,
791 file: FileId,
792 range: rowan::TextRange,
793 ) -> Option<&brink_ir::lir::UfcsVerdict> {
794 ufcs_resolution_query(&self.salsa, self.project)
795 .table
796 .get(file, range)
797 }
798
799 /// Every UFCS call site (`recv.verb(args)`) whose verdict desugars to a
800 /// free function targeting `target`, project-wide (issue #1539) — reads
801 /// the same memoized `ufcs_resolution_query` table
802 /// [`ufcs_verdict`](Self::ufcs_verdict) does. The `find_references`/
803 /// `rename` counterpart to that single-site lookup: renaming or listing
804 /// references to a free function must also reach every UFCS call site
805 /// that resolves to it, not just its plain `ResolutionMap` references.
806 #[must_use]
807 pub fn ufcs_call_sites_for_target(
808 &self,
809 target: DefinitionId,
810 ) -> Vec<(FileId, rowan::TextRange)> {
811 ufcs_resolution_query(&self.salsa, self.project)
812 .table
813 .call_sites_for_target(target)
814 }
815
816 /// Per-file type diagnostics (`type_diagnostics(FileId)`). Advisory-only
817 /// in this slice — always empty (see `type_diagnostics_query`'s docs).
818 pub fn type_diagnostics(&self, id: FileId) -> Option<&[Diagnostic]> {
819 let file = self.files.get(&id)?;
820 Some(type_diagnostics_query(&self.salsa, self.project, *file).as_slice())
821 }
822
823 /// Whole-project LIR lowering (layer 3). `None` until an entry point is
824 /// set via [`set_entry`](Self::set_entry).
825 pub fn lir_product(&self) -> Option<&LirProduct> {
826 self.project.entry(&self.salsa)?;
827 Some(lir_query(&self.salsa, self.project))
828 }
829
830 /// Whether the project has at least one Error-severity diagnostic after
831 /// suppression filtering (issue #791 / FG-4a) — the narrow boolean
832 /// projection [`lir_product`](Self::lir_product)'s gate reads instead of
833 /// the full diagnostics vector. `false` (never `None`) when no entry
834 /// point is set, matching `partition_diagnostics`'s empty-`errors`
835 /// default in that case. Exposed for the dependency-edge tests; a
836 /// diagnostics-content edit that leaves this boolean unchanged proves
837 /// the cutoff seam backdated.
838 pub fn has_errors(&self) -> bool {
839 has_errors_query(&self.salsa, self.project)
840 }
841
842 /// FG-4d non-re-execution probe (issue #830): the `Arc<ScopeChunk>` the
843 /// per-knot LIR chunk memo stores for the `knot_index`-th knot of `file`.
844 /// `Arc::ptr_eq` on the result across an edit proves the memo validated
845 /// without re-executing — salsa only hands back the same allocation when
846 /// a query's inputs (this file's HIR, the project resolutions, and the
847 /// struct-shape projection) are all unchanged. Exposed for the
848 /// dependency-edge tests, mirroring [`resolutions_index`](Self::resolutions_index).
849 #[doc(hidden)]
850 #[must_use]
851 pub fn knot_chunk(&self, file: FileId, knot_index: u32) -> Arc<brink_ir::lir::ScopeChunk> {
852 let key = KnotChunkKey::new(&self.salsa, file, knot_index);
853 lir_knot_chunk_query(&self.salsa, self.project, key).chunk
854 }
855
856 /// FG-4e non-re-execution probe (issue #839): the
857 /// `Arc<brink_ir::lir::PreludeDecls>` the whole-project prelude-decls
858 /// memo stores. `Arc::ptr_eq` on the result across an edit proves the
859 /// memo validated without re-executing — salsa only hands back the same
860 /// allocation when every entry-reachable file's [decl-only HIR
861 /// projection](crate::queries::PreludeDeclsResult) is unchanged, which
862 /// holds across a knot-body-only edit. Exposed for the dependency-edge
863 /// tests, mirroring [`knot_chunk`](Self::knot_chunk).
864 #[doc(hidden)]
865 #[must_use]
866 pub fn lir_prelude_decls(&self) -> Arc<brink_ir::lir::PreludeDecls> {
867 lir_prelude_decls_query(&self.salsa, self.project).decls
868 }
869
870 /// Whole-project compile to [`brink_format::StoryData`] (layer 3,
871 /// `story_data()`). `None` until an entry point is set via
872 /// [`set_entry`](Self::set_entry).
873 pub fn story_data(&self) -> Option<&CompileProduct> {
874 self.project.entry(&self.salsa)?;
875 Some(story_data_query(&self.salsa, self.project))
876 }
877
878 /// Snapshot salsa's memo-table memory usage — one row per salsa
879 /// ingredient (input/tracked struct or memoized query function), sorted
880 /// for deterministic output. Behind the `memory-introspection` feature
881 /// (issue #529); see `brink-test-harness`'s `editor_session_bench`.
882 #[cfg(feature = "memory-introspection")]
883 pub fn memory_snapshot(&self) -> Vec<crate::memory::IngredientMemory> {
884 crate::memory::snapshot(&self.salsa)
885 }
886
887 // ── Internal helpers ──────────────────────────────────────────────
888
889 fn include_graph(&self) -> &crate::include_graph::IncludeGraph {
890 include_graph_query(&self.salsa, self.project)
891 }
892
893 /// Test-only escape hatch: hand out the raw salsa handle and project
894 /// input so crate-internal `#[cfg(test)]` code elsewhere (e.g.
895 /// `queries::tests`) can call `pub(crate)` queries — `call_graph_query`,
896 /// `def_effect_atoms_query` — directly instead of only through this
897 /// façade's own accessors (issue #1736 finding: a direct edge-set
898 /// parity guard needs both raw pieces).
899 #[cfg(test)]
900 pub(crate) fn salsa_and_project(&self) -> (&BrinkDatabase, ProjectInput) {
901 (&self.salsa, self.project)
902 }
903}
904
905impl Default for ProjectDb {
906 fn default() -> Self {
907 Self::new()
908 }
909}
910
911/// Resolve an INCLUDE path relative to the including file's directory.
912///
913/// Uses string-based path manipulation (`rfind('/')`) rather than
914/// `std::path::Path` to avoid platform-specific separator issues and
915/// to work in WASM contexts. The joined path is normalized so `.`/`..`
916/// segments collapse to a clean project-relative key (e.g.
917/// `a/b/../d.ink` → `a/d.ink`) — consistent across the compiler, runtime,
918/// and IDE so upward-relative includes resolve to real files.
919pub fn resolve_include_path(from_file: &str, include_path: &str) -> String {
920 let joined = match from_file.rfind('/') {
921 Some(i) => format!("{}/{include_path}", &from_file[..i]),
922 None => include_path.to_string(),
923 };
924 normalize_path(&joined)
925}
926
927/// Collapse `.` and `..` segments in a `/`-separated path. A `..` pops the
928/// previous real segment; a `..` with nothing to pop (or above an existing
929/// `..`) is kept literally rather than escaping the root. A leading `/`
930/// (absolute path) is preserved — the test harness and disk-backed compiles
931/// resolve against absolute filesystem paths.
932fn normalize_path(path: &str) -> String {
933 let absolute = path.starts_with('/');
934 let mut out: Vec<&str> = Vec::new();
935 for seg in path.split('/') {
936 match seg {
937 "" | "." => {}
938 ".." if matches!(out.last(), Some(&s) if s != "..") => {
939 out.pop();
940 }
941 s => out.push(s),
942 }
943 }
944 let joined = out.join("/");
945 if absolute {
946 format!("/{joined}")
947 } else {
948 joined
949 }
950}
951
952/// Compute the relative INCLUDE target to reach `to_file` from `from_file`'s
953/// directory — the inverse of [`resolve_include_path`]:
954/// `normalize(resolve_include_path(from_file, compute_relative_path(from_file, to_file))) == to_file`
955/// for both forward and `..`-traversing layouts. Used when a file is
956/// renamed/moved to rewrite every `INCLUDE` that points at it (and the moved
957/// file's own includes).
958pub fn compute_relative_path(from_file: &str, to_file: &str) -> String {
959 let mut from_dirs: Vec<&str> = from_file.split('/').collect();
960 from_dirs.pop(); // drop the including file's own name
961 let to_all: Vec<&str> = to_file.split('/').collect();
962 let Some((to_name, to_dirs)) = to_all.split_last() else {
963 return to_file.to_owned();
964 };
965
966 // Longest common directory prefix.
967 let mut k = 0;
968 while k < from_dirs.len() && k < to_dirs.len() && from_dirs[k] == to_dirs[k] {
969 k += 1;
970 }
971
972 let mut parts: Vec<&str> = Vec::new();
973 parts.extend(std::iter::repeat_n("..", from_dirs.len() - k));
974 parts.extend_from_slice(&to_dirs[k..]);
975 parts.push(to_name);
976 parts.join("/")
977}
978
979#[cfg(test)]
980mod path_tests {
981 use super::{compute_relative_path, resolve_include_path};
982
983 #[test]
984 fn resolve_forward_includes() {
985 assert_eq!(
986 resolve_include_path("src/main.ink", "utils.ink"),
987 "src/utils.ink"
988 );
989 assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
990 assert_eq!(resolve_include_path("a/b/c.ink", "d/e.ink"), "a/b/d/e.ink");
991 }
992
993 #[test]
994 fn resolve_normalizes_dot_and_dotdot() {
995 assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
996 assert_eq!(resolve_include_path("a/b/c.ink", "./d.ink"), "a/b/d.ink");
997 assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
998 assert_eq!(
999 resolve_include_path("a/b/c.ink", "../x/../d.ink"),
1000 "a/d.ink"
1001 );
1002 }
1003
1004 #[test]
1005 fn compute_relative_is_inverse_of_resolve() {
1006 // (from including file, target file) round-trips through resolve.
1007 let cases = [
1008 ("main.ink", "scenes/intro.ink"), // move into a subdir
1009 ("a/b/c.ink", "a/d.ink"), // sibling dir (needs ..)
1010 ("a/b/c.ink", "a/b/renamed.ink"), // rename in place
1011 ("scenes/intro.ink", "lib.ink"), // up to root (needs ..)
1012 ("a/b/c.ink", "x/y/z.ink"), // fully divergent
1013 ("main.ink", "other.ink"), // both at root
1014 ];
1015 for (from, to) in cases {
1016 let rel = compute_relative_path(from, to);
1017 assert_eq!(
1018 resolve_include_path(from, &rel),
1019 to,
1020 "round-trip failed for from={from} to={to} rel={rel}",
1021 );
1022 }
1023 }
1024
1025 #[test]
1026 fn resolve_preserves_absolute_paths() {
1027 // The test harness / disk-backed compiles pass absolute paths — the
1028 // leading slash must survive normalization.
1029 assert_eq!(
1030 resolve_include_path("/proj/tier3/main.ink", "included.ink"),
1031 "/proj/tier3/included.ink",
1032 );
1033 assert_eq!(
1034 resolve_include_path("/proj/a/b/c.ink", "../d.ink"),
1035 "/proj/a/d.ink"
1036 );
1037 }
1038
1039 #[test]
1040 fn compute_relative_rename_in_place_is_bare_name() {
1041 assert_eq!(
1042 compute_relative_path("a/b/c.ink", "a/b/renamed.ink"),
1043 "renamed.ink"
1044 );
1045 assert_eq!(
1046 compute_relative_path("main.ink", "renamed.ink"),
1047 "renamed.ink"
1048 );
1049 }
1050
1051 #[test]
1052 fn compute_relative_move_shallower_is_bare_name() {
1053 // Regression (#318): after a shallower move (chapters/main.ink →
1054 // main.ink), the outbound-INCLUDE rewrite relativizes the resolved
1055 // target against the NEW path. From the root, a root-level sibling is a
1056 // bare name — no stale `chapters/` or `../` prefix.
1057 assert_eq!(compute_relative_path("main.ink", "host.ink"), "host.ink");
1058 // And from the OLD subdir location, the same root-level target is
1059 // `../host.ink` — relative to `chapters/`, reaching the root needs `..`.
1060 // (This is the value resolve→compute round-trips through; the rewrite
1061 // uses the NEW path above to drop the prefix.)
1062 assert_eq!(
1063 compute_relative_path("chapters/main.ink", "host.ink"),
1064 "../host.ink"
1065 );
1066 assert_eq!(
1067 resolve_include_path("chapters/main.ink", "../host.ink"),
1068 "host.ink"
1069 );
1070 }
1071}
1072
1073#[cfg(test)]
1074mod native_seam_tests {
1075 use super::ProjectDb;
1076
1077 use brink_analyzer::{AnalysisOptions, Dialect};
1078
1079 /// B0.10a gate (issue #1106): a native `.brink` file, registered by the
1080 /// plain public db API, must compile all the way through the *real* salsa
1081 /// pipeline to `StoryData` — proving the frontend seam in `lowered_query`
1082 /// dispatches on the `.brink` extension and that everything downstream of
1083 /// lowering is frontend-agnostic. This flow falls off the end (the
1084 /// `lower_native` implicit `-> DONE`), so no explicit terminator is needed.
1085 #[test]
1086 fn native_brink_file_compiles_through_to_story_data() {
1087 let mut db = ProjectDb::new();
1088 // Native compiles under the brink dialect (the analysis posture the
1089 // first-light native harness uses).
1090 db.set_analysis_options(AnalysisOptions {
1091 dialect: Dialect::Brink,
1092 ..AnalysisOptions::default()
1093 });
1094 let id = db.set_file(
1095 "scene.brink",
1096 "flow main() {\n Hello, world.\n}\n".to_owned(),
1097 );
1098 db.set_entry("scene.brink");
1099
1100 // No parse/lowering diagnostics, and the non-suppressible admission
1101 // gate is clean.
1102 assert_eq!(
1103 db.file_diagnostics(id),
1104 Some(&[][..]),
1105 "native lowering must produce no per-file diagnostics"
1106 );
1107 assert_eq!(
1108 db.admission_diagnostics(id),
1109 Some(&[][..]),
1110 "native HIR must pass the B0.3 admission gate"
1111 );
1112
1113 // End-to-end: parse_native -> lower_native -> analyze -> LIR ->
1114 // codegen -> StoryData, all via the public `story_data()` accessor.
1115 let product = db
1116 .story_data()
1117 .expect("entry is set, so story_data is Some");
1118 assert!(
1119 product.errors.is_empty(),
1120 "native compile must be error-free, got: {:?}",
1121 product.errors
1122 );
1123 assert!(
1124 product.story.is_some(),
1125 "native compile must yield a StoryData"
1126 );
1127 }
1128
1129 /// SAVE-KEY INVARIANT (decision-log 2026-07-22 "Native module identity"):
1130 /// a native symbol's `DefinitionId` is hashed from its **path-derived**
1131 /// module (`native_module_path`) + name, and nothing else. Two properties
1132 /// follow, both of which keep player saves stable across recompiles:
1133 /// 1. Identity is qualified by the file's *location* — the same-named
1134 /// flow at a different path is a different definition.
1135 /// 2. Identity is independent of `FileId` (assigned in discovery order)
1136 /// — adding an unrelated file cannot change it.
1137 #[test]
1138 fn native_definition_id_is_path_qualified_and_fileid_independent() {
1139 use brink_format::DefinitionId;
1140
1141 fn hero_id(files: &[(&str, &str)]) -> DefinitionId {
1142 let mut db = ProjectDb::new();
1143 db.set_analysis_options(AnalysisOptions {
1144 dialect: Dialect::Brink,
1145 ..AnalysisOptions::default()
1146 });
1147 for (path, src) in files {
1148 db.set_file(path, (*src).to_owned());
1149 }
1150 db.set_entry(files[0].0);
1151 let index = db.symbol_index();
1152 let ids = index.by_name.get("hero").expect("`hero` is defined");
1153 assert_eq!(ids.len(), 1, "exactly one `hero`");
1154 ids[0]
1155 }
1156
1157 let hero = "flow hero() {\n hi\n}\n";
1158 let other = "flow other() {\n x\n}\n";
1159
1160 // `flow hero()` in `market/barter.brink`, compiled alone → FileId(0).
1161 let solo = hero_id(&[("market/barter.brink", hero)]);
1162
1163 // Same file, but an unrelated sibling is registered FIRST, so
1164 // `market/barter.brink` is now FileId(1) — its `FileId` shifted. If
1165 // `FileId` leaked into identity, `hero`'s `DefinitionId` would change.
1166 let with_sibling = hero_id(&[("aaa/early.brink", other), ("market/barter.brink", hero)]);
1167 assert_eq!(
1168 solo, with_sibling,
1169 "adding a file must not change `market/barter`'s `hero` identity — \
1170 `FileId` must never enter `DefinitionId`"
1171 );
1172
1173 // The SAME `flow hero()` at a DIFFERENT path is a different module, so a
1174 // distinct identity — the path-derived module qualifies.
1175 let elsewhere = hero_id(&[("shop/wares.brink", hero)]);
1176 assert_ne!(
1177 solo, elsewhere,
1178 "`story::market::barter::hero` and `story::shop::wares::hero` must be distinct"
1179 );
1180 }
1181
1182 /// NATIVE `@[was]` RENAME MIGRATION (issue #1286, the save-key companion to
1183 /// path-derived native module identity). A native module's `DefinitionId`
1184 /// is `hash(native_module_path, name)`, so *moving* the file changes every
1185 /// id and breaks saves keyed on the old ones. A file-level
1186 /// `@[was("old::path")]` is the migration record: it must emit an
1187 /// `AliasEntry { old, new }` mapping each pre-rename id to its current one,
1188 /// so `brink-runtime`'s miss-path lookup still resolves an old save.
1189 #[test]
1190 fn native_was_annotation_produces_pre_rename_alias() {
1191 use brink_format::DefinitionId;
1192
1193 fn hero_id(path: &str, src: &str) -> DefinitionId {
1194 let mut db = ProjectDb::new();
1195 db.set_analysis_options(AnalysisOptions {
1196 dialect: Dialect::Brink,
1197 ..AnalysisOptions::default()
1198 });
1199 db.set_file(path, src.to_owned());
1200 db.set_entry(path);
1201 let index = db.symbol_index();
1202 index.by_name.get("hero").expect("`hero` is defined")[0]
1203 }
1204
1205 let plain = "flow hero() {\n hi\n}\n";
1206 // The OLD identity: `hero` when the module lived at `old/barter.brink`
1207 // (module `story::old::barter`), before any rename.
1208 let old_id = hero_id("old/barter.brink", plain);
1209
1210 // The renamed module: same `hero`, now at `market/barter.brink`
1211 // (module `story::market::barter`), declaring where it came from.
1212 let renamed_src = "@[was(\"story::old::barter\")]\nflow hero() {\n hi\n}\n";
1213 let mut db = ProjectDb::new();
1214 db.set_analysis_options(AnalysisOptions {
1215 dialect: Dialect::Brink,
1216 ..AnalysisOptions::default()
1217 });
1218 db.set_file("market/barter.brink", renamed_src.to_owned());
1219 db.set_entry("market/barter.brink");
1220
1221 let index = db.symbol_index();
1222 let new_id = index.by_name.get("hero").expect("`hero` is defined")[0];
1223 assert_ne!(
1224 old_id, new_id,
1225 "moving the module changes `hero`'s identity — that is the problem \
1226 `@[was]` migrates"
1227 );
1228 assert!(
1229 index
1230 .aliases
1231 .iter()
1232 .any(|a| a.old == old_id && a.new == new_id),
1233 "`@[was(\"story::old::barter\")]` must alias the pre-rename id to the \
1234 current one; aliases: {:?}",
1235 index.aliases
1236 );
1237 }
1238
1239 /// The end-to-end proof #1286 claimed but issue #1355 found not actually
1240 /// reachable: the unquoted `::`-path spelling of `@[was(…)]` (issue
1241 /// #1349's grammar) must migrate a pre-rename `DefinitionId` exactly like
1242 /// the quoted-string spelling in
1243 /// [`native_was_annotation_produces_pre_rename_alias`] — same alias, not
1244 /// just a clean parse.
1245 #[test]
1246 fn native_was_annotation_unquoted_path_produces_pre_rename_alias() {
1247 use brink_format::DefinitionId;
1248
1249 fn hero_id(path: &str, src: &str) -> DefinitionId {
1250 let mut db = ProjectDb::new();
1251 db.set_analysis_options(AnalysisOptions {
1252 dialect: Dialect::Brink,
1253 ..AnalysisOptions::default()
1254 });
1255 db.set_file(path, src.to_owned());
1256 db.set_entry(path);
1257 let index = db.symbol_index();
1258 index.by_name.get("hero").expect("`hero` is defined")[0]
1259 }
1260
1261 let plain = "flow hero() {\n hi\n}\n";
1262 // The OLD identity: `hero` when the module lived at `old/barter.brink`
1263 // (module `story::old::barter`), before any rename.
1264 let old_id = hero_id("old/barter.brink", plain);
1265
1266 // The renamed module: same `hero`, now at `market/barter.brink`
1267 // (module `story::market::barter`), declaring where it came from
1268 // using the **unquoted** `::`-path spelling.
1269 let renamed_src = "@[was(story::old::barter)]\nflow hero() {\n hi\n}\n";
1270 let mut db = ProjectDb::new();
1271 db.set_analysis_options(AnalysisOptions {
1272 dialect: Dialect::Brink,
1273 ..AnalysisOptions::default()
1274 });
1275 db.set_file("market/barter.brink", renamed_src.to_owned());
1276 db.set_entry("market/barter.brink");
1277
1278 let index = db.symbol_index();
1279 let new_id = index.by_name.get("hero").expect("`hero` is defined")[0];
1280 assert_ne!(
1281 old_id, new_id,
1282 "moving the module changes `hero`'s identity — that is the problem \
1283 `@[was]` migrates"
1284 );
1285 assert!(
1286 index
1287 .aliases
1288 .iter()
1289 .any(|a| a.old == old_id && a.new == new_id),
1290 "`@[was(story::old::barter)]` (unquoted) must alias the pre-rename \
1291 id to the current one; aliases: {:?}",
1292 index.aliases
1293 );
1294 }
1295
1296 /// The negative control for [`native_was_annotation_produces_pre_rename_alias`]:
1297 /// WITHOUT `@[was]`, the same physical move changes `hero`'s `DefinitionId`
1298 /// and produces **no** alias — an old save silently fails to resolve. This
1299 /// is exactly why `@[was]` is required before native is used for real saves.
1300 #[test]
1301 fn native_rename_without_was_leaves_no_alias() {
1302 use brink_format::DefinitionId;
1303
1304 fn setup(path: &str, src: &str) -> (DefinitionId, Vec<brink_format::AliasEntry>) {
1305 let mut db = ProjectDb::new();
1306 db.set_analysis_options(AnalysisOptions {
1307 dialect: Dialect::Brink,
1308 ..AnalysisOptions::default()
1309 });
1310 db.set_file(path, src.to_owned());
1311 db.set_entry(path);
1312 let index = db.symbol_index();
1313 let id = index.by_name.get("hero").expect("`hero` is defined")[0];
1314 (id, index.aliases.clone())
1315 }
1316
1317 let plain = "flow hero() {\n hi\n}\n";
1318 let (old_id, _) = setup("old/barter.brink", plain);
1319 let (new_id, aliases) = setup("market/barter.brink", plain);
1320 assert_ne!(old_id, new_id, "the move still changes identity");
1321 assert!(
1322 !aliases.iter().any(|a| a.old == old_id),
1323 "no `@[was]` means no migration path — the old id is unrecoverable"
1324 );
1325 }
1326
1327 /// NATIVE MULTI-FILE LINKING (issue #1296, decision-log 2026-07-23): a
1328 /// multi-file native project links **every discovered `.brink` module**
1329 /// into the one `StoryData` — the discovery set is the compilation unit.
1330 /// Native files carry no `INCLUDE` edges, so before the codegen-closure
1331 /// fix only the *entry* file reached codegen and the sibling module's
1332 /// definitions silently vanished from the compiled story. Here the sibling
1333 /// `helper.brink` is never referenced from `main.brink`, yet its `helper`
1334 /// flow must still appear as a container in the linked `StoryData`.
1335 #[test]
1336 fn native_sibling_module_links_into_one_story_data() {
1337 let mut db = ProjectDb::new();
1338 db.set_analysis_options(AnalysisOptions {
1339 dialect: Dialect::Brink,
1340 ..AnalysisOptions::default()
1341 });
1342 db.set_file("main.brink", "flow main() {\n Hello.\n}\n".to_owned());
1343 db.set_file("helper.brink", "flow helper() {\n Aside.\n}\n".to_owned());
1344 db.set_entry("main.brink");
1345
1346 let product = db
1347 .story_data()
1348 .expect("entry is set, so story_data is Some");
1349 assert!(
1350 product.errors.is_empty(),
1351 "two clean native modules must compile: {:?}",
1352 product.errors
1353 );
1354 let story = product
1355 .story
1356 .as_ref()
1357 .expect("native multi-file compile must yield a StoryData");
1358
1359 let index = db.symbol_index();
1360 let main_id = index.by_name.get("main").expect("`main` is defined")[0];
1361 let helper_id = index.by_name.get("helper").expect("`helper` is defined")[0];
1362
1363 assert!(
1364 story.containers.iter().any(|c| c.id == main_id),
1365 "entry module's `main` flow must be linked"
1366 );
1367 assert!(
1368 story.containers.iter().any(|c| c.id == helper_id),
1369 "unreferenced sibling module's `helper` flow must ALSO be linked — \
1370 the whole discovered `.brink` tree is the compilation unit"
1371 );
1372 }
1373
1374 /// RUST PARITY (issue #1296, decision-log 2026-07-23): a `.brink` file that
1375 /// fails to compile is an error **even if no other module references it**.
1376 /// Because the native codegen closure is every discovered module, a broken
1377 /// unreferenced sibling's Error-severity diagnostic (`E037` for a malformed
1378 /// flow header) is inside the build gate's closure and must fail the whole
1379 /// build — the entry file's clean flow does not rescue it.
1380 #[test]
1381 fn broken_unreferenced_native_sibling_fails_the_build() {
1382 let mut db = ProjectDb::new();
1383 db.set_analysis_options(AnalysisOptions {
1384 dialect: Dialect::Brink,
1385 ..AnalysisOptions::default()
1386 });
1387 db.set_file("main.brink", "flow main() {\n Hello.\n}\n".to_owned());
1388 // Malformed flow header (bad parameter list) → a `ParseSeverity::Error`
1389 // that surfaces as the non-suppressible `E037` compile diagnostic.
1390 db.set_file("broken.brink", "flow broken( {\n}\n".to_owned());
1391 db.set_entry("main.brink");
1392
1393 let product = db
1394 .story_data()
1395 .expect("entry is set, so story_data is Some");
1396 assert!(
1397 !product.errors.is_empty(),
1398 "a broken unreferenced native sibling must fail the build"
1399 );
1400 assert!(
1401 product.story.is_none(),
1402 "no StoryData may be produced when any discovered native module is broken"
1403 );
1404 }
1405
1406 /// The seam must not leak: an `.ink` file still runs the ink frontend and
1407 /// compiles as before (the native parser is never invoked for it).
1408 #[test]
1409 fn ink_file_still_compiles_via_ink_frontend() {
1410 let mut db = ProjectDb::new();
1411 db.set_file("main.ink", "Hello, world.\n-> DONE\n".to_owned());
1412 db.set_entry("main.ink");
1413 let product = db.story_data().expect("entry is set");
1414 assert!(
1415 product.errors.is_empty(),
1416 "ink path unchanged: {:?}",
1417 product.errors
1418 );
1419 assert!(product.story.is_some());
1420 }
1421}
1422
1423#[cfg(test)]
1424mod remove_readd_tests {
1425 use super::ProjectDb;
1426 use brink_ir::FileId;
1427
1428 #[test]
1429 fn readd_reuses_original_file_id() {
1430 let mut db = ProjectDb::new();
1431 let a = db.set_file("a.ink", "== ka ==\ntext\n".to_owned());
1432 let b = db.set_file("b.ink", "== kb ==\ntext\n".to_owned());
1433 assert_eq!(a, FileId(0));
1434 assert_eq!(b, FileId(1));
1435
1436 db.remove_file("a.ink");
1437 let a2 = db.set_file("a.ink", "== ka2 ==\ntext\n".to_owned());
1438 assert_eq!(a2, a, "re-added path must reuse its original FileId");
1439
1440 // A genuinely new path still gets a fresh id — reuse never aliases.
1441 let c = db.set_file("c.ink", "== kc ==\ntext\n".to_owned());
1442 assert_eq!(c, FileId(2));
1443
1444 // The project file list stays sorted by FileId even though the
1445 // reinstated id (0) is smaller than the ids minted after it.
1446 let ids: Vec<FileId> = db.file_ids().collect();
1447 assert_eq!(ids, vec![a, b, c]);
1448 let meta_ids: Vec<FileId> = db
1449 .file_metadata()
1450 .into_iter()
1451 .map(|(id, _, _)| id)
1452 .collect();
1453 assert_eq!(meta_ids, vec![a, b, c]);
1454 }
1455
1456 #[test]
1457 fn removed_file_is_invisible_to_every_accessor() {
1458 let mut db = ProjectDb::new();
1459 db.set_file("main.ink", "INCLUDE sub.ink\n-> DONE\n".to_owned());
1460 let sub = db.set_file("sub.ink", "== s ==\ntext\n-> DONE\n".to_owned());
1461
1462 db.remove_file("sub.ink");
1463
1464 assert_eq!(db.file_id("sub.ink"), None);
1465 assert_eq!(db.file_path(sub), None);
1466 assert!(db.file_ids().all(|id| id != sub));
1467 assert!(db.file_metadata().iter().all(|(id, _, _)| *id != sub));
1468 assert!(db.analysis_inputs().iter().all(|(id, _, _)| *id != sub));
1469 assert!(db.source(sub).is_none());
1470 assert!(db.parse(sub).is_none());
1471 assert!(db.hir(sub).is_none());
1472 assert!(db.manifest(sub).is_none());
1473 assert!(db.diagnostics(sub).is_none());
1474 assert!(db.suppressions(sub).is_none());
1475 assert!(db.resolve(sub).is_none());
1476 }
1477
1478 #[test]
1479 fn removed_include_matches_never_added_diagnostics() {
1480 let source = "INCLUDE sub.ink\n-> s\n";
1481
1482 // Db where sub.ink existed and was removed.
1483 let mut removed = ProjectDb::new();
1484 removed.set_file("main.ink", source.to_owned());
1485 removed.set_file("sub.ink", "== s ==\ntext\n-> DONE\n".to_owned());
1486 removed.set_entry("main.ink");
1487 // Pull through the whole pipeline while sub.ink is live, so the
1488 // removed-state read below exercises invalidation, not a cold start.
1489 assert!(removed.story_data().is_some());
1490 removed.remove_file("sub.ink");
1491
1492 // Db where sub.ink never existed (main.ink gets FileId(0) in both).
1493 let mut fresh = ProjectDb::new();
1494 fresh.set_file("main.ink", source.to_owned());
1495 fresh.set_entry("main.ink");
1496
1497 let main_removed = removed.file_id("main.ink").expect("main");
1498 let main_fresh = fresh.file_id("main.ink").expect("main");
1499 assert_eq!(main_removed, main_fresh);
1500 assert_eq!(
1501 removed.diagnostics(main_removed),
1502 fresh.diagnostics(main_fresh),
1503 "a removed INCLUDE target must diagnose exactly like a missing one"
1504 );
1505 assert_eq!(
1506 removed.story_data().map(|p| p.errors.clone()),
1507 fresh.story_data().map(|p| p.errors.clone()),
1508 );
1509 }
1510
1511 #[test]
1512 fn readd_recomputes_from_new_content() {
1513 let mut db = ProjectDb::new();
1514 let id = db.set_file("a.ink", "== old_knot ==\ntext\n-> DONE\n".to_owned());
1515 // Materialize memos for the original content.
1516 assert!(
1517 db.manifest(id)
1518 .is_some_and(|m| m.knots.iter().any(|k| k.name == "old_knot"))
1519 );
1520
1521 db.remove_file("a.ink");
1522 let id2 = db.set_file("a.ink", "== new_knot ==\ntext\n-> DONE\n".to_owned());
1523 assert_eq!(id2, id);
1524
1525 let manifest = db.manifest(id).expect("manifest after re-add");
1526 assert!(
1527 manifest.knots.iter().any(|k| k.name == "new_knot"),
1528 "re-added content must win over stale memos"
1529 );
1530 assert!(
1531 !manifest.knots.iter().any(|k| k.name == "old_knot"),
1532 "old content must not survive the tombstone round-trip"
1533 );
1534 assert_eq!(db.source(id), Some("== new_knot ==\ntext\n-> DONE\n"));
1535 }
1536
1537 #[test]
1538 fn remove_clears_entry_and_readd_does_not_restore_it() {
1539 let mut db = ProjectDb::new();
1540 db.set_file("main.ink", "-> DONE\n".to_owned());
1541 db.set_entry("main.ink");
1542 assert!(db.entry().is_some());
1543
1544 db.remove_file("main.ink");
1545 assert_eq!(db.entry(), None);
1546
1547 db.set_file("main.ink", "-> DONE\n".to_owned());
1548 assert_eq!(db.entry(), None, "re-add must not silently restore entry");
1549 }
1550}
1551
1552#[cfg(test)]
1553mod reachable_tests {
1554 use super::ProjectDb;
1555
1556 /// Load files then read reachability — the include graph is a tracked
1557 /// query, so no rebuild step is needed regardless of insertion order.
1558 fn db_with(files: &[(&str, &str)]) -> ProjectDb {
1559 let mut db = ProjectDb::new();
1560 for (path, src) in files {
1561 db.set_file(path, (*src).to_owned());
1562 }
1563 db
1564 }
1565
1566 #[test]
1567 fn entry_is_always_reachable_from_itself() {
1568 let db = db_with(&[("main.ink", "== hub ==\ntext\n")]);
1569 let main = db.file_id("main.ink").expect("main");
1570 let reachable = db.reachable_from(main);
1571 assert_eq!(reachable.into_iter().collect::<Vec<_>>(), vec![main]);
1572 }
1573
1574 #[test]
1575 fn direct_includes_are_reachable() {
1576 let db = db_with(&[
1577 ("main.ink", "INCLUDE a.ink\nINCLUDE b.ink\n"),
1578 ("a.ink", "== a ==\n"),
1579 ("b.ink", "== b ==\n"),
1580 ]);
1581 let main = db.file_id("main.ink").expect("main");
1582 let a = db.file_id("a.ink").expect("a");
1583 let b = db.file_id("b.ink").expect("b");
1584 let reachable: Vec<_> = db.reachable_from(main).into_iter().collect();
1585 assert!(reachable.contains(&main));
1586 assert!(reachable.contains(&a));
1587 assert!(reachable.contains(&b));
1588 assert_eq!(reachable.len(), 3);
1589 }
1590
1591 #[test]
1592 fn transitive_includes_are_reachable() {
1593 let db = db_with(&[
1594 ("main.ink", "INCLUDE a.ink\n"),
1595 ("a.ink", "INCLUDE b.ink\n"),
1596 ("b.ink", "== b ==\n"),
1597 ("unrelated.ink", "== x ==\n"),
1598 ]);
1599 let main = db.file_id("main.ink").expect("main");
1600 let a = db.file_id("a.ink").expect("a");
1601 let b = db.file_id("b.ink").expect("b");
1602 let unrelated = db.file_id("unrelated.ink").expect("unrelated");
1603 let reachable = db.reachable_from(main);
1604 assert!(reachable.contains(&main));
1605 assert!(reachable.contains(&a));
1606 assert!(reachable.contains(&b));
1607 assert!(
1608 !reachable.contains(&unrelated),
1609 "unrelated file is not reachable"
1610 );
1611 }
1612
1613 #[test]
1614 fn reachable_terminates_on_cycles() {
1615 // a -> b -> a; reachability must not loop forever.
1616 let db = db_with(&[("a.ink", "INCLUDE b.ink\n"), ("b.ink", "INCLUDE a.ink\n")]);
1617 let a = db.file_id("a.ink").expect("a");
1618 let b = db.file_id("b.ink").expect("b");
1619 let reachable: Vec<_> = db.reachable_from(a).into_iter().collect();
1620 assert!(reachable.contains(&a));
1621 assert!(reachable.contains(&b));
1622 assert_eq!(reachable.len(), 2);
1623 }
1624}
1625
1626#[cfg(test)]
1627mod type_inference_tests {
1628 use super::ProjectDb;
1629 use brink_ir::SymbolKind;
1630
1631 /// End-to-end reachability proof (TM-1, #617): `infer_body`/
1632 /// `type_inference` are reachable through the same public `ProjectDb`
1633 /// surface every other query surfaces through, and return a real
1634 /// inferred type for a param whose body use pins it — not a stub.
1635 #[test]
1636 fn infer_body_is_reachable_through_project_db() {
1637 let mut db = ProjectDb::new();
1638 db.set_file(
1639 "main.ink",
1640 "=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n".to_owned(),
1641 );
1642 db.set_entry("main.ink");
1643
1644 let index = db.symbol_index();
1645 let heal = index
1646 .by_name
1647 .get("heal")
1648 .and_then(|ids| ids.first())
1649 .copied()
1650 .expect("heal knot indexed");
1651 assert_eq!(
1652 index.symbols.get(&heal).map(|i| i.kind),
1653 Some(SymbolKind::Knot)
1654 );
1655
1656 let body = db.infer_body(heal).expect("heal has an inferable body");
1657 assert_eq!(body.params.len(), 1);
1658 assert_eq!(body.params[0].0, "hp");
1659 assert_eq!(body.params[0].1.display(), "int");
1660
1661 // Same view via the whole-project result and via `type_diagnostics`
1662 // (advisory-only: empty, but reachable and correctly shaped).
1663 assert!(db.type_inference().signatures.contains_key(&heal));
1664 let main = db.file_id("main.ink").expect("main");
1665 assert_eq!(db.type_diagnostics(main), Some(&[][..]));
1666 }
1667
1668 #[test]
1669 fn infer_body_is_none_for_a_non_callable_def() {
1670 let mut db = ProjectDb::new();
1671 db.set_file("main.ink", "VAR gold = 10\n-> DONE\n".to_owned());
1672 let index = db.symbol_index();
1673 let gold = index
1674 .by_name
1675 .get("gold")
1676 .and_then(|ids| ids.first())
1677 .copied()
1678 .expect("gold indexed");
1679 assert_eq!(db.infer_body(gold), None, "a VAR has no inferable body");
1680 }
1681
1682 #[test]
1683 fn type_inference_is_independent_of_the_compile_path() {
1684 // Pulling every other query surface (diagnostics, story_data) first
1685 // — neither reads `type_inference_query` (see its module docs), so
1686 // this exercises `infer_body` cold, after, and must still return the
1687 // same correct result: nothing about the compile path's query graph
1688 // secretly depends on inference having (or not having) run yet.
1689 let mut db = ProjectDb::new();
1690 db.set_file(
1691 "main.ink",
1692 "=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n".to_owned(),
1693 );
1694 db.set_entry("main.ink");
1695 let main = db.file_id("main.ink").expect("main");
1696 let _ = db.diagnostics(main);
1697 let _ = db.story_data();
1698
1699 let index = db.symbol_index();
1700 let heal = index
1701 .by_name
1702 .get("heal")
1703 .and_then(|ids| ids.first())
1704 .copied()
1705 .expect("heal knot indexed");
1706 let body = db.infer_body(heal).expect("heal has an inferable body");
1707 assert_eq!(body.params[0].1.display(), "int");
1708 }
1709}
1710
1711#[cfg(test)]
1712mod module_tests {
1713 //! M-1 modules (docs/modules-spec.md §1/§5): end-to-end reachability of
1714 //! module-qualified identity through the same public `ProjectDb`
1715 //! symbol-index surface the compiler (and IDE) use — the path that feeds
1716 //! codegen and the checked-in `.inkb`.
1717 use super::ProjectDb;
1718 use brink_ir::DiagnosticCode;
1719
1720 fn knot_id(db: &ProjectDb, name: &str) -> u64 {
1721 db.symbol_index()
1722 .by_name
1723 .get(name)
1724 .and_then(|ids| ids.first())
1725 .map(|id| id.to_raw())
1726 .expect("knot indexed")
1727 }
1728
1729 #[test]
1730 fn undeclared_file_keeps_bare_identity() {
1731 // The identity gate, exercised through the real db pipeline: an
1732 // undeclared single-file module hashes exactly as a bare-name build.
1733 // Byte-exact identity of a knot in an undeclared file is pinned in
1734 // the analyzer's `known_good_bare_definition_ids`; here we prove the
1735 // db path itself resolves an undeclared file to a *non-qualifying*
1736 // module — two undeclared files with different stems hash the knot
1737 // identically (the stem never enters the hash).
1738 let mut one = ProjectDb::new();
1739 one.set_file("story.ink", "== start ==\nHi\n-> DONE\n".to_owned());
1740 let mut other = ProjectDb::new();
1741 other.set_file("elsewhere.ink", "== start ==\nHi\n-> DONE\n".to_owned());
1742 assert_eq!(
1743 knot_id(&one, "start"),
1744 knot_id(&other, "start"),
1745 "two undeclared files (different stems) hash the knot identically"
1746 );
1747 }
1748
1749 #[test]
1750 fn declared_module_qualifies_identity_through_db() {
1751 let mut bare = ProjectDb::new();
1752 bare.set_file("story.ink", "== start ==\nHi\n-> DONE\n".to_owned());
1753
1754 let mut declared = ProjectDb::new();
1755 declared.set_file(
1756 "story.ink",
1757 "#@module(quest)\n== start ==\nHi\n-> DONE\n".to_owned(),
1758 );
1759
1760 assert_ne!(
1761 knot_id(&bare, "start"),
1762 knot_id(&declared, "start"),
1763 "declaring a module must qualify (change) the knot's DefinitionId"
1764 );
1765 }
1766
1767 #[test]
1768 fn included_file_inherits_module_identity() {
1769 // Standalone `part.ink` (undeclared) vs the same file INCLUDE-glued
1770 // under a declaring head — the included knot's identity must follow
1771 // the head's module.
1772 let mut standalone = ProjectDb::new();
1773 standalone.set_file("part.ink", "== helper ==\nHi\n-> DONE\n".to_owned());
1774
1775 let mut glued = ProjectDb::new();
1776 glued.set_file(
1777 "head.ink",
1778 "#@module(quest)\nINCLUDE part.ink\n-> helper\n".to_owned(),
1779 );
1780 glued.set_file("part.ink", "== helper ==\nHi\n-> DONE\n".to_owned());
1781 glued.set_entry("head.ink");
1782
1783 assert_ne!(
1784 knot_id(&standalone, "helper"),
1785 knot_id(&glued, "helper"),
1786 "an INCLUDE-glued file inherits the includer's declared module"
1787 );
1788 }
1789
1790 #[test]
1791 fn stem_collision_with_declared_module_is_e085_through_db() {
1792 let mut db = ProjectDb::new();
1793 // `a.ink` declares module `shared`; `shared.ink` is an undeclared
1794 // file whose stem is *also* `shared` — the forbidden footgun.
1795 db.set_file("a.ink", "#@module(shared)\n== a_knot ==\nHi\n".to_owned());
1796 db.set_file("shared.ink", "== other ==\nHi\n".to_owned());
1797
1798 let codes: Vec<_> = db
1799 .symbol_index_diagnostics()
1800 .iter()
1801 .map(|d| d.code)
1802 .collect();
1803 assert!(
1804 codes.contains(&DiagnosticCode::E085),
1805 "expected E085 stem collision, got {codes:?}"
1806 );
1807 }
1808
1809 /// End-to-end reachability for M-2 cross-module visibility (§4/§7): a
1810 /// `#@private` knot in declared module `quest`, diverted to from a
1811 /// different declared module `town`, surfaces `E087` through the same
1812 /// production diagnostics path the compiler/studio read.
1813 #[test]
1814 fn private_cross_module_reference_is_e087_through_db() {
1815 let mut db = ProjectDb::new();
1816 db.set_file(
1817 "quest.ink",
1818 "#@module(quest)\n== ambush ==\n#@private\nGotcha!\n-> DONE\n".to_owned(),
1819 );
1820 let town = db.set_file(
1821 "town.ink",
1822 "#@module(town)\n== square ==\nHi\n-> ambush\n".to_owned(),
1823 );
1824
1825 let codes: Vec<_> = db
1826 .diagnostics(town)
1827 .unwrap_or_default()
1828 .iter()
1829 .map(|d| d.code)
1830 .collect();
1831 assert!(
1832 codes.contains(&DiagnosticCode::E087),
1833 "expected E087 private-cross-module reference, got {codes:?}"
1834 );
1835 }
1836
1837 /// An explicitly `#@public` knot in another declared module, diverted to
1838 /// from a file that **imports** it, resolves cleanly — no E087 (public,
1839 /// visibility-keyed) and no E025 (the import licenses the crossing, §2).
1840 #[test]
1841 fn imported_public_cross_module_reference_is_clean() {
1842 let mut db = ProjectDb::new();
1843 db.set_file(
1844 "quest.ink",
1845 "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1846 );
1847 let town = db.set_file(
1848 "town.ink",
1849 "#@module(town)\nIMPORT { ambush } FROM quest\n== square ==\nHi\n-> ambush\n"
1850 .to_owned(),
1851 );
1852
1853 let codes: Vec<_> = db
1854 .diagnostics(town)
1855 .unwrap_or_default()
1856 .iter()
1857 .map(|d| d.code)
1858 .collect();
1859 assert!(
1860 !codes.contains(&DiagnosticCode::E087),
1861 "public cross-module reference must not be E087, got {codes:?}"
1862 );
1863 assert!(
1864 !codes.contains(&DiagnosticCode::E025),
1865 "an imported public cross-module reference must not be E025, got {codes:?}"
1866 );
1867 }
1868
1869 /// M-2c (§2): a *public* knot in another **declared** module, referenced
1870 /// from a file that did **not** `IMPORT` it, is `E025` — names cross
1871 /// module boundaries only via import. Bringing the name in (bare import)
1872 /// clears it (proven by `imported_public_cross_module_reference_is_clean`).
1873 #[test]
1874 fn public_cross_module_reference_without_import_is_e025() {
1875 let mut db = ProjectDb::new();
1876 db.set_file(
1877 "quest.ink",
1878 "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1879 );
1880 let town = db.set_file(
1881 "town.ink",
1882 "#@module(town)\n== square ==\nHi\n-> ambush\n".to_owned(),
1883 );
1884
1885 let codes: Vec<_> = db
1886 .diagnostics(town)
1887 .unwrap_or_default()
1888 .iter()
1889 .map(|d| d.code)
1890 .collect();
1891 assert!(
1892 codes.contains(&DiagnosticCode::E025),
1893 "a non-imported public cross-module reference must be E025, got {codes:?}"
1894 );
1895 }
1896
1897 /// The qualified import form (`IMPORT quest`) also licenses references to
1898 /// the module's exports — no E025.
1899 #[test]
1900 fn qualified_import_licenses_cross_module_reference() {
1901 let mut db = ProjectDb::new();
1902 db.set_file(
1903 "quest.ink",
1904 "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1905 );
1906 let town = db.set_file(
1907 "town.ink",
1908 "#@module(town)\nIMPORT quest\n== square ==\nHi\n-> ambush\n".to_owned(),
1909 );
1910
1911 let codes: Vec<_> = db
1912 .diagnostics(town)
1913 .unwrap_or_default()
1914 .iter()
1915 .map(|d| d.code)
1916 .collect();
1917 assert!(
1918 !codes.contains(&DiagnosticCode::E025),
1919 "a qualified import must license the crossing, got {codes:?}"
1920 );
1921 }
1922
1923 /// The import-required restriction is keyed on the *target's* module being
1924 /// **declared**: a plain multi-file project with no `#@module` anywhere is
1925 /// one big default-public module (§3), so a cross-*file* bare reference
1926 /// keeps resolving with no E025 — the byte-identical legacy guarantee.
1927 #[test]
1928 fn cross_file_reference_in_undeclared_project_is_not_e025() {
1929 let mut db = ProjectDb::new();
1930 // `main.ink` INCLUDEs `helpers.ink`; neither declares a module.
1931 db.set_file(
1932 "helpers.ink",
1933 "== helper ==\nHelping.\n-> DONE\n".to_owned(),
1934 );
1935 let main = db.set_file(
1936 "main.ink",
1937 "INCLUDE helpers.ink\n== start ==\nHi\n-> helper\n".to_owned(),
1938 );
1939
1940 let codes: Vec<_> = db
1941 .diagnostics(main)
1942 .unwrap_or_default()
1943 .iter()
1944 .map(|d| d.code)
1945 .collect();
1946 assert!(
1947 !codes.contains(&DiagnosticCode::E025),
1948 "an undeclared multi-file project must not trip the import gate, got {codes:?}"
1949 );
1950 }
1951
1952 /// M-2c (§2): a `IMPORT quest` (qualified) whose module name also names a
1953 /// knot visible bare in the same file makes `quest.y` ambiguous — `E091`.
1954 #[test]
1955 fn qualified_import_colliding_with_definition_is_e091() {
1956 let mut db = ProjectDb::new();
1957 db.set_file(
1958 "quest.ink",
1959 "#@module(quest)\n== ambush ==\n#@public\nGotcha!\n-> DONE\n".to_owned(),
1960 );
1961 // `town` has its own knot named `quest` AND imports module `quest`.
1962 let town = db.set_file(
1963 "town.ink",
1964 "#@module(town)\nIMPORT quest\n== quest ==\nHi\n-> DONE\n".to_owned(),
1965 );
1966
1967 let codes: Vec<_> = db
1968 .diagnostics(town)
1969 .unwrap_or_default()
1970 .iter()
1971 .map(|d| d.code)
1972 .collect();
1973 assert!(
1974 codes.contains(&DiagnosticCode::E091),
1975 "expected E091 qualified module-vs-definition ambiguity, got {codes:?}"
1976 );
1977 }
1978
1979 /// The `E092` redundant-override warning is reachable end-to-end: a
1980 /// `#@private` on a definition in a **declared** module restates that
1981 /// module's private-by-default (§4), so it is redundant.
1982 #[test]
1983 fn redundant_private_in_declared_module_is_e092() {
1984 let mut db = ProjectDb::new();
1985 let f = db.set_file(
1986 "quest.ink",
1987 "#@module(quest)\n== ambush ==\n#@private\nHi\n-> DONE\n".to_owned(),
1988 );
1989
1990 let codes: Vec<_> = db
1991 .diagnostics(f)
1992 .unwrap_or_default()
1993 .iter()
1994 .map(|d| d.code)
1995 .collect();
1996 assert!(
1997 codes.contains(&DiagnosticCode::E092),
1998 "expected E092 redundant-override warning, got {codes:?}"
1999 );
2000 }
2001
2002 /// A `#@public` on a definition in an **undeclared** stem-module restates
2003 /// the public-by-default (§4) — also redundant (`E092`).
2004 #[test]
2005 fn redundant_public_in_undeclared_module_is_e092() {
2006 let mut db = ProjectDb::new();
2007 let f = db.set_file(
2008 "story.ink",
2009 "== ambush ==\n#@public\nHi\n-> DONE\n".to_owned(),
2010 );
2011
2012 let codes: Vec<_> = db
2013 .diagnostics(f)
2014 .unwrap_or_default()
2015 .iter()
2016 .map(|d| d.code)
2017 .collect();
2018 assert!(
2019 codes.contains(&DiagnosticCode::E092),
2020 "expected E092 redundant-override warning, got {codes:?}"
2021 );
2022 }
2023
2024 /// A module importing itself surfaces `E090` through the db.
2025 #[test]
2026 fn self_import_is_e090_through_db() {
2027 let mut db = ProjectDb::new();
2028 let f = db.set_file(
2029 "quest.ink",
2030 "#@module(quest)\nIMPORT quest\n== start ==\nHi\n-> DONE\n".to_owned(),
2031 );
2032
2033 let codes: Vec<_> = db
2034 .diagnostics(f)
2035 .unwrap_or_default()
2036 .iter()
2037 .map(|d| d.code)
2038 .collect();
2039 assert!(
2040 codes.contains(&DiagnosticCode::E090),
2041 "expected E090 self-import, got {codes:?}"
2042 );
2043 }
2044
2045 /// A bare import naming a definition the (declared) module does not
2046 /// publicly export surfaces `E088`; a repeated local name surfaces
2047 /// `E089`.
2048 #[test]
2049 fn unresolved_and_duplicate_bare_import_through_db() {
2050 let mut db = ProjectDb::new();
2051 db.set_file(
2052 "quest.ink",
2053 "#@module(quest)\n== ambush ==\n#@public\nHi\n-> DONE\n".to_owned(),
2054 );
2055 // `ambush` twice (duplicate local name) and `nope` (not exported).
2056 let town = db.set_file(
2057 "town.ink",
2058 "#@module(town)\nIMPORT { ambush, ambush, nope } FROM quest\n== square ==\nHi\n-> DONE\n"
2059 .to_owned(),
2060 );
2061
2062 let codes: Vec<_> = db
2063 .diagnostics(town)
2064 .unwrap_or_default()
2065 .iter()
2066 .map(|d| d.code)
2067 .collect();
2068 assert!(
2069 codes.contains(&DiagnosticCode::E089),
2070 "expected E089 duplicate import, got {codes:?}"
2071 );
2072 assert!(
2073 codes.contains(&DiagnosticCode::E088),
2074 "expected E088 unresolved import, got {codes:?}"
2075 );
2076 }
2077
2078 /// A single file declaring `#@module(quest)` whose knots reference
2079 /// sibling definitions bare (issue #795): a self-reference inside the
2080 /// declared module must never be E087, no matter which of the file's
2081 /// symbols the index's `HashMap` happens to yield first (locals carry
2082 /// `module: None` and must not poison the file's module attribution).
2083 /// The bug was nondeterministic — repeated fresh-db runs (fresh
2084 /// `HashMap` seeds each time) cover the iteration-order space; the
2085 /// order-independent analyzer-level regression lives in
2086 /// `brink-analyzer`'s `modules::tests`.
2087 #[test]
2088 fn single_file_declared_module_self_reference_is_not_e087() {
2089 for _ in 0..16 {
2090 let mut db = ProjectDb::new();
2091 let f = db.set_file(
2092 "main.ink",
2093 "#@module(quest)\nVAR target = -> ambush\n-> ambush\n== ambush ==\n~ temp x = 1\nGotcha!\n-> reader\n== reader ==\nDone.\n-> DONE\n".to_owned(),
2094 );
2095
2096 let codes: Vec<_> = db
2097 .diagnostics(f)
2098 .unwrap_or_default()
2099 .iter()
2100 .map(|d| d.code)
2101 .collect();
2102 assert!(
2103 !codes.contains(&DiagnosticCode::E087),
2104 "same-module self-reference must not be E087, got {codes:?}"
2105 );
2106 }
2107 }
2108
2109 /// A file that belongs to a declared module but declares no top-level
2110 /// symbols of its own (only root content) must still resolve to that
2111 /// module — a referrer in the *same* declared module referencing a
2112 /// `#@private` sibling def must not be wrongly flagged `E087` just
2113 /// because the referrer's own module couldn't be derived from its
2114 /// (nonexistent) symbols.
2115 #[test]
2116 fn symbol_less_file_in_same_module_is_not_e087() {
2117 let mut db = ProjectDb::new();
2118 db.set_file(
2119 "a.ink",
2120 "#@module(town)\n== square ==\n#@private\nGotcha!\n-> DONE\n".to_owned(),
2121 );
2122 // `b.ink` declares the same module but has only root content — no
2123 // knot/VAR/CONST/LIST/STRUCT of its own.
2124 let b = db.set_file("b.ink", "#@module(town)\n-> square\n".to_owned());
2125
2126 let codes: Vec<_> = db
2127 .diagnostics(b)
2128 .unwrap_or_default()
2129 .iter()
2130 .map(|d| d.code)
2131 .collect();
2132 assert!(
2133 !codes.contains(&DiagnosticCode::E087),
2134 "same-module reference from a symbol-less file must not be E087, got {codes:?}"
2135 );
2136 }
2137
2138 /// A symbol-less file (only root content) that imports its own declared
2139 /// module must still trip `E090` self-import — the same derivation gap
2140 /// that caused the `E087` false positive above also caused this false
2141 /// negative (the referrer's own module resolved to `None`).
2142 #[test]
2143 fn symbol_less_file_self_import_is_e090() {
2144 let mut db = ProjectDb::new();
2145 let f = db.set_file(
2146 "quest.ink",
2147 "#@module(quest)\nIMPORT quest\n-> DONE\n".to_owned(),
2148 );
2149
2150 let codes: Vec<_> = db
2151 .diagnostics(f)
2152 .unwrap_or_default()
2153 .iter()
2154 .map(|d| d.code)
2155 .collect();
2156 assert!(
2157 codes.contains(&DiagnosticCode::E090),
2158 "expected E090 self-import from a symbol-less file, got {codes:?}"
2159 );
2160 }
2161
2162 // ── M-2c cross-module collisions (issue #784, decision-log
2163 // "Cross-module name collisions" 2026-07-14) ────────────────────────
2164
2165 fn brink_opts() -> brink_analyzer::AnalysisOptions {
2166 brink_analyzer::AnalysisOptions {
2167 dialect: brink_analyzer::Dialect::Brink,
2168 ..brink_analyzer::AnalysisOptions::default()
2169 }
2170 }
2171
2172 /// Two **different** declared modules exporting the same public knot
2173 /// name now **coexist** under `dialect = brink` (M-2d, issue #790 —
2174 /// the E096 stopgap relaxed): no diagnostic, and both definitions land
2175 /// in the index, through the same `symbol_index_query` path the
2176 /// compiler/studio read.
2177 #[test]
2178 fn cross_declared_module_duplicate_knot_coexists_under_brink() {
2179 let mut db = ProjectDb::new();
2180 db.set_analysis_options(brink_opts());
2181 db.set_file(
2182 "quest.ink",
2183 "#@module(quest)\n== start ==\n#@public\nHi from quest\n-> DONE\n".to_owned(),
2184 );
2185 db.set_file(
2186 "town.ink",
2187 "#@module(town)\n== start ==\n#@public\nHi from town\n-> DONE\n".to_owned(),
2188 );
2189
2190 let diags = db.symbol_index_diagnostics();
2191 assert!(
2192 diags.iter().all(|d| d.code != DiagnosticCode::E096),
2193 "E096 is relaxed — cross-declared-module homonyms must coexist, got {diags:?}"
2194 );
2195 // Both public `start` knots survive in the index under the shared
2196 // bare name — the raw material import-scoped resolution binds
2197 // per-importer.
2198 let index = db.symbol_index();
2199 assert_eq!(
2200 index.by_name.get("start").map(Vec::len),
2201 Some(2),
2202 "both modules' `start` knots must be indexed"
2203 );
2204 }
2205
2206 /// Two files sharing the **same** declared module (a multi-file module)
2207 /// that both define `start` stay the ordinary within-module warning
2208 /// (`E022`) — never `E096` — even under `dialect = brink`.
2209 #[test]
2210 fn same_declared_module_duplicate_knot_still_warns_e022() {
2211 let mut db = ProjectDb::new();
2212 db.set_analysis_options(brink_opts());
2213 db.set_file(
2214 "a.ink",
2215 "#@module(quest)\n== start ==\nHi from a\n-> DONE\n".to_owned(),
2216 );
2217 db.set_file(
2218 "b.ink",
2219 "#@module(quest)\n== start ==\nHi from b\n-> DONE\n".to_owned(),
2220 );
2221
2222 let codes: Vec<_> = db
2223 .symbol_index_diagnostics()
2224 .iter()
2225 .map(|d| d.code)
2226 .collect();
2227 assert!(
2228 codes.contains(&DiagnosticCode::E022),
2229 "expected the within-module E022 warning, got {codes:?}"
2230 );
2231 assert!(
2232 !codes.contains(&DiagnosticCode::E096),
2233 "same declared module must never escalate to E096, got {codes:?}"
2234 );
2235 }
2236
2237 /// Undeclared (legacy/soup) files duplicating a knot name are unchanged
2238 /// by M-2c: still `E022`, never `E096`, even under `dialect = brink`.
2239 #[test]
2240 fn undeclared_duplicate_knot_unchanged_under_brink() {
2241 let mut db = ProjectDb::new();
2242 db.set_analysis_options(brink_opts());
2243 db.set_file("a.ink", "== start ==\nHi from a\n-> DONE\n".to_owned());
2244 db.set_file("b.ink", "== start ==\nHi from b\n-> DONE\n".to_owned());
2245
2246 let codes: Vec<_> = db
2247 .symbol_index_diagnostics()
2248 .iter()
2249 .map(|d| d.code)
2250 .collect();
2251 assert!(
2252 codes.contains(&DiagnosticCode::E022),
2253 "expected the legacy E022 warning, got {codes:?}"
2254 );
2255 assert!(
2256 !codes.contains(&DiagnosticCode::E096),
2257 "undeclared legacy soup must never escalate to E096, got {codes:?}"
2258 );
2259 }
2260
2261 /// Under `strict-ink` (the default), a cross-declared-module duplicate
2262 /// stays the ordinary `E022` warning — the compat corpus is untouched.
2263 #[test]
2264 fn cross_declared_module_duplicate_stays_e022_under_strict_ink() {
2265 let mut db = ProjectDb::new();
2266 // Default AnalysisOptions -> Dialect::StrictInk; no set_analysis_options call.
2267 db.set_file(
2268 "quest.ink",
2269 "#@module(quest)\n== start ==\n#@public\nHi from quest\n-> DONE\n".to_owned(),
2270 );
2271 db.set_file(
2272 "town.ink",
2273 "#@module(town)\n== start ==\n#@public\nHi from town\n-> DONE\n".to_owned(),
2274 );
2275
2276 let codes: Vec<_> = db
2277 .symbol_index_diagnostics()
2278 .iter()
2279 .map(|d| d.code)
2280 .collect();
2281 assert!(
2282 codes.contains(&DiagnosticCode::E022),
2283 "expected E022 under strict-ink, got {codes:?}"
2284 );
2285 assert!(
2286 !codes.contains(&DiagnosticCode::E096),
2287 "strict-ink must never see E096, got {codes:?}"
2288 );
2289 }
2290
2291 /// The M-2d flagship (issue #790): two modules each export a public
2292 /// `ambush`; two files each bare-import a *different* one. Import-scoped
2293 /// resolution binds each file's `-> ambush` to the module it imported —
2294 /// not to the flat duplicate-winner — and the whole project compiles
2295 /// clean (no E025 import-required, no E096). Driven through the real
2296 /// `ProjectDb`/`resolve_query` path the compiler reads.
2297 #[test]
2298 fn two_modules_export_ambush_each_file_binds_its_own() {
2299 let mut db = ProjectDb::new();
2300 db.set_analysis_options(brink_opts());
2301 db.set_file(
2302 "quest_a.ink",
2303 "#@module(quest_a)\n== ambush ==\n#@public\nFrom A\n-> DONE\n".to_owned(),
2304 );
2305 db.set_file(
2306 "quest_b.ink",
2307 "#@module(quest_b)\n== ambush ==\n#@public\nFrom B\n-> DONE\n".to_owned(),
2308 );
2309 let main_a = db.set_file(
2310 "main_a.ink",
2311 "IMPORT { ambush } FROM quest_a\n-> ambush\n".to_owned(),
2312 );
2313 let main_b = db.set_file(
2314 "main_b.ink",
2315 "IMPORT { ambush } FROM quest_b\n-> ambush\n".to_owned(),
2316 );
2317
2318 // The two `ambush` knots coexist, module-qualified.
2319 let index = db.symbol_index();
2320 let ambush_ids = index.by_name.get("ambush").expect("both ambush knots");
2321 assert_eq!(ambush_ids.len(), 2, "both modules' `ambush` are indexed");
2322 let module_of = |target: brink_format::DefinitionId| -> Option<String> {
2323 index
2324 .symbols
2325 .get(&target)
2326 .and_then(|info| info.module.clone())
2327 };
2328
2329 // Each importing file's `-> ambush` binds to the module it imported.
2330 let targets = |file| -> Vec<brink_format::DefinitionId> {
2331 let (map, _diags) = db.resolve(file).expect("file resolves");
2332 map.iter().map(|r| r.target).collect()
2333 };
2334 let a_targets = targets(main_a);
2335 assert!(
2336 a_targets
2337 .iter()
2338 .any(|&t| module_of(t).as_deref() == Some("quest_a")),
2339 "main_a's `ambush` must bind to module quest_a, got {:?}",
2340 a_targets.iter().map(|&t| module_of(t)).collect::<Vec<_>>()
2341 );
2342 assert!(
2343 !a_targets
2344 .iter()
2345 .any(|&t| module_of(t).as_deref() == Some("quest_b")),
2346 "main_a must NOT bind quest_b's `ambush`"
2347 );
2348
2349 let b_targets = targets(main_b);
2350 assert!(
2351 b_targets
2352 .iter()
2353 .any(|&t| module_of(t).as_deref() == Some("quest_b")),
2354 "main_b's `ambush` must bind to module quest_b"
2355 );
2356 assert!(
2357 !b_targets
2358 .iter()
2359 .any(|&t| module_of(t).as_deref() == Some("quest_a")),
2360 "main_b must NOT bind quest_a's `ambush`"
2361 );
2362
2363 // The whole project compiles clean: no import-required error, no
2364 // stopgap collision error, on any file.
2365 for file in [main_a, main_b] {
2366 let diags = db.diagnostics(file).expect("diagnostics");
2367 assert!(
2368 diags
2369 .iter()
2370 .all(|d| d.code != DiagnosticCode::E025 && d.code != DiagnosticCode::E096),
2371 "import-scoped resolution must leave the correctly-imported file clean, got {diags:?}"
2372 );
2373 }
2374 }
2375
2376 /// `ImportScope` granularity regression (issue #790 review): a bare
2377 /// `IMPORT { other } FROM quest_a` must not license `quest_a`'s *other*
2378 /// public exports — only the name actually named. Two modules each
2379 /// export public `ambush`; the referring file bare-imports an unrelated
2380 /// name from `quest_a` and bare-imports `ambush` itself only from
2381 /// `quest_b`. Before the fix, `ImportScope` collapsed every import to
2382 /// just its module name, so `quest_a` counted as "imported" for *any*
2383 /// name — `-> ambush` could silently mis-resolve to `quest_a.ambush`
2384 /// and then draw a spurious `E025` telling the author to import `ambush`
2385 /// from `quest_a`, on a program that should compile clean. Resolution
2386 /// and the `E025` checker must agree at (module, name) granularity for
2387 /// bare imports.
2388 #[test]
2389 fn bare_import_is_name_precise_no_spurious_e025() {
2390 let mut db = ProjectDb::new();
2391 db.set_analysis_options(brink_opts());
2392 db.set_file(
2393 "quest_a.ink",
2394 "#@module(quest_a)\n== ambush ==\n#@public\nFrom A\n-> DONE\n== other ==\n#@public\nOther A\n-> DONE\n".to_owned(),
2395 );
2396 db.set_file(
2397 "quest_b.ink",
2398 "#@module(quest_b)\n== ambush ==\n#@public\nFrom B\n-> DONE\n".to_owned(),
2399 );
2400 let main = db.set_file(
2401 "main.ink",
2402 "IMPORT { other } FROM quest_a\nIMPORT { ambush } FROM quest_b\n-> ambush\n".to_owned(),
2403 );
2404
2405 let index = db.symbol_index();
2406 let module_of = |target: brink_format::DefinitionId| -> Option<String> {
2407 index
2408 .symbols
2409 .get(&target)
2410 .and_then(|info| info.module.clone())
2411 };
2412
2413 let (map, _diags) = db.resolve(main).expect("file resolves");
2414 let targets: Vec<brink_format::DefinitionId> = map.iter().map(|r| r.target).collect();
2415 assert!(
2416 targets
2417 .iter()
2418 .any(|&t| module_of(t).as_deref() == Some("quest_b")),
2419 "bare-importing `ambush` from quest_b must bind it to quest_b, got {:?}",
2420 targets.iter().map(|&t| module_of(t)).collect::<Vec<_>>()
2421 );
2422 assert!(
2423 !targets
2424 .iter()
2425 .any(|&t| module_of(t).as_deref() == Some("quest_a")),
2426 "bare-importing only `other` from quest_a must NOT license quest_a's `ambush`, got {:?}",
2427 targets.iter().map(|&t| module_of(t)).collect::<Vec<_>>()
2428 );
2429
2430 let diags = db.diagnostics(main).expect("diagnostics");
2431 assert!(
2432 diags.iter().all(|d| d.code != DiagnosticCode::E025),
2433 "a correctly bare-imported `ambush` must never draw a spurious E025 \
2434 pointing at the unrelated module that only imported a different name, got {diags:?}"
2435 );
2436 }
2437}