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