brink_analyzer/manifest.rs
1use std::collections::BTreeMap;
2use std::collections::hash_map::DefaultHasher;
3use std::hash::{Hash, Hasher};
4
5use brink_format::{DefinitionId, DefinitionTag};
6use brink_ir::{
7 Diagnostic, DiagnosticCode, FileId, LocalSymbol, Scope, SymbolIndex, SymbolInfo, SymbolKind,
8 SymbolManifest, Visibility, VisibilityMark,
9};
10
11/// Apply declaration-flips-default (modules-spec §4) to an explicit
12/// `#@private`/`#@public` override, returning the effective [`Visibility`]
13/// and whether the override was *redundant* (restated the module default).
14///
15/// A declared module (`declared == true`) defaults private; an undeclared
16/// stem-module defaults public.
17fn effective_visibility(declared: bool, mark: Option<VisibilityMark>) -> (Visibility, bool) {
18 let default = if declared {
19 Visibility::Private
20 } else {
21 Visibility::Public
22 };
23 match mark {
24 None => (default, false),
25 Some(VisibilityMark::Private) => (Visibility::Private, default == Visibility::Private),
26 Some(VisibilityMark::Public) => (Visibility::Public, default == Visibility::Public),
27 }
28}
29
30/// A file's resolved module (M-1, docs/modules-spec.md §1/§5).
31///
32/// Computed upstream (in `brink-db`, where file stems, `#@module`
33/// declarations, and the INCLUDE graph are all known) and threaded into
34/// the symbol index so `DefinitionId` hashing can qualify names by
35/// module. Only a **declared** module (`#@module(name)` present) qualifies
36/// identity; an undeclared stem-module contributes nothing to the hash, so
37/// the entire pre-modules corpus keeps byte-identical `DefinitionId`s.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResolvedModule {
40 /// The module's name (stem for undeclared, `#@module` argument for
41 /// declared).
42 pub name: String,
43 /// `true` when the file (or its INCLUDE head) carries `#@module`.
44 pub declared: bool,
45 /// The module's old name, from a `#@was(old_name)` on **any** file
46 /// sharing this resolved module (M-3, docs/modules-spec.md §5) —
47 /// aggregated by `brink-db::modules::resolve_modules` so every file in
48 /// a multi-file module sees the same rename regardless of which file
49 /// declared it. `None` for a module with no recorded rename, and
50 /// always `None` for an undeclared stem-module (scoped to declared
51 /// modules only — see `ModuleDecl::was`'s doc).
52 pub was: Option<String>,
53}
54
55/// Map from file to its resolved module. Absent entries (and undeclared
56/// modules) hash by bare name — byte-identical to the pre-modules scheme.
57pub type ModuleMap = BTreeMap<FileId, ResolvedModule>;
58
59/// Merge per-file symbol manifests into a unified symbol index.
60///
61/// Returns the index and any diagnostics (e.g. duplicate definitions).
62/// Names hash by bare name (no module qualification) — the pre-modules
63/// behavior. Use [`merge_manifests_with_modules`] to qualify identity by
64/// declared module.
65pub fn merge_manifests(files: &[(FileId, &SymbolManifest)]) -> (SymbolIndex, Vec<Diagnostic>) {
66 merge_manifests_with_modules(files, &ModuleMap::new(), crate::Dialect::default(), false)
67}
68
69/// Merge per-file symbol manifests, qualifying `DefinitionId`s by each
70/// file's **declared** module (M-1, docs/modules-spec.md §5).
71///
72/// A file whose [`ResolvedModule::declared`] is `true` hashes its symbol
73/// names as `(module, name)`; every other file (undeclared stem-modules,
74/// or files absent from `modules`) hashes by bare name, byte-identically
75/// to [`merge_manifests`]. This is the byte-identity guarantee the whole
76/// pre-modules corpus relies on.
77///
78/// `dialect` gates M-2d cross-module coexistence (issue #790, decision-log
79/// "Cross-module name collisions" 2026-07-14, endgame clause): under
80/// `Dialect::Brink`, a same-name/same-kind pair whose two owning files
81/// declared *different* modules is no longer a duplicate — both are inserted
82/// and coexist, and import-scoped resolution ([`crate::ImportScope`]) binds
83/// each reference to the module its file imported. This relaxes the M-2c/#793
84/// `E096` stopgap. A duplicate within one declared module, or involving any
85/// undeclared/legacy file, still warns (`E022`/`E023`/`E026`) and drops the
86/// later definition. `merge_manifests`'s empty `ModuleMap` can never produce
87/// a declared-module pair, so its `Dialect::default()` is inert.
88///
89/// `is_native` (issue #1562 review finding) is the same widening
90/// [`is_cross_declared_module_collision`]'s own doc describes: `true` lets
91/// M-2d coexistence apply regardless of `dialect`, for callers with a real
92/// `Language` classification of the project (`brink-db`'s `symbol_index_query`,
93/// via `project_is_native`). `merge_manifests`'s `false` is byte-identical to
94/// before this parameter existed.
95pub fn merge_manifests_with_modules(
96 files: &[(FileId, &SymbolManifest)],
97 modules: &ModuleMap,
98 dialect: crate::Dialect,
99 is_native: bool,
100) -> (SymbolIndex, Vec<Diagnostic>) {
101 let mut index = SymbolIndex::default();
102 let mut diagnostics = Vec::new();
103
104 for &(file_id, manifest) in files {
105 // Only a *declared* module qualifies identity; undeclared
106 // stem-modules (and files absent from `modules`) hash bare.
107 let resolved = modules.get(&file_id).filter(|m| m.declared);
108 let module = ModuleCtx {
109 name: resolved.map(|m| m.name.as_str()),
110 // M-3 (docs/modules-spec.md §5): the module's old name, if any
111 // file sharing it declared `#@was` —
112 // `brink-db::modules::resolve_modules` aggregates this onto
113 // every file's `ResolvedModule` already, so a single per-file
114 // lookup here sees it regardless of which file in a multi-file
115 // module carried the directive.
116 was: resolved.and_then(|m| m.was.as_deref()),
117 };
118 insert_file_symbols(
119 &mut index,
120 &mut diagnostics,
121 file_id,
122 module,
123 manifest,
124 dialect,
125 is_native,
126 );
127 }
128
129 (index, diagnostics)
130}
131
132/// A file's resolved module, bundled for the `insert_*` helpers below
133/// (keeps their argument count under clippy's limit — `module` and
134/// `module_was` are always passed together).
135#[derive(Clone, Copy)]
136struct ModuleCtx<'a> {
137 /// The **declared** module's current name; `None` for an undeclared
138 /// stem-module (identity hashes bare).
139 name: Option<&'a str>,
140 /// The module's old name from a `#@was` (M-3, docs/modules-spec.md
141 /// §5), if any file sharing this module recorded one.
142 was: Option<&'a str>,
143}
144
145/// Insert every symbol declared in one file's manifest, qualifying named
146/// declarations by `module` (M-1). Locals stay unqualified (`LocalVar`,
147/// container-scoped, never serialized).
148fn insert_file_symbols(
149 index: &mut SymbolIndex,
150 diagnostics: &mut Vec<Diagnostic>,
151 file_id: FileId,
152 module: ModuleCtx<'_>,
153 manifest: &SymbolManifest,
154 dialect: crate::Dialect,
155 is_native: bool,
156) {
157 use DiagnosticCode::{E022, E023, E026};
158 use SymbolKind::{Constant, External, Knot, Label, List, ListItem, Stitch, Struct, Variable};
159
160 let groups: [(&[brink_ir::DeclaredSymbol], SymbolKind, DiagnosticCode); 9] = [
161 (&manifest.knots, Knot, E022),
162 (&manifest.stitches, Stitch, E022),
163 (&manifest.variables, Variable, E023),
164 (&manifest.constants, Constant, E023),
165 (&manifest.lists, List, E023),
166 (&manifest.structs, Struct, E023),
167 (&manifest.externals, External, E023),
168 (&manifest.labels, Label, E022),
169 (&manifest.list_items, ListItem, E026),
170 ];
171 for (syms, kind, dup_code) in groups {
172 for sym in syms {
173 insert_symbol(
174 index,
175 diagnostics,
176 file_id,
177 module,
178 sym,
179 kind,
180 dup_code,
181 dialect,
182 is_native,
183 );
184 }
185 }
186 for local in &manifest.locals {
187 insert_local(index, file_id, local);
188 }
189
190 push_transitive_aliases(index, module, manifest);
191}
192
193/// M-3 transitive alias entries (issue #1671, docs/modules-spec.md §5): a
194/// `#@was` on a knot or stitch re-keys **every** stitch/label beneath it,
195/// because their qualified names embed the container's name — but
196/// [`insert_symbol`] mints only the container's *own* alias entry, so a
197/// declared rename still lost every descendant's durable state. The loader
198/// cannot recover this at load time (a `DefinitionId` is a hash; no path can
199/// be derived from one), so it must be materialized here, where the
200/// compiler still knows every descendant's qualified path.
201///
202/// Mirrors [`insert_symbol`]'s own-alias construction, additive and
203/// independent per level — a knot renamed *simultaneously* with one of its
204/// own stitches produces one edge per level (`old_knot.new_stitch...` and
205/// `new_knot.old_stitch...`), not the doubly-old `old_knot.old_stitch...`
206/// form, the same documented limitation [`insert_symbol`]'s module+name
207/// case already carries. Table growth is bounded by the renamed container's
208/// subtree size (docs/format-spec.md's alias-table section).
209fn push_transitive_aliases(
210 index: &mut SymbolIndex,
211 module: ModuleCtx<'_>,
212 manifest: &SymbolManifest,
213) {
214 // A knot rename re-keys every stitch and label qualified under its
215 // (bare) name.
216 for knot in &manifest.knots {
217 let Some((old_name, _range)) = &knot.was else {
218 continue;
219 };
220 if old_name == &knot.name {
221 continue; // already diagnosed E095 at lowering; no bridge to mint
222 }
223 let new_prefix = format!("{}.", knot.name);
224 let old_prefix = format!("{old_name}.");
225 for (descendants, kind) in [
226 (&manifest.stitches, SymbolKind::Stitch),
227 (&manifest.labels, SymbolKind::Label),
228 ] {
229 push_prefix_bridged_aliases(index, module, descendants, kind, &new_prefix, &old_prefix);
230 }
231 }
232
233 // A stitch rename re-keys every label qualified under it. `stitch.was`
234 // is already fully qualified as `{knot}.{old_stitch}` (`lower_stitch`
235 // qualifies it with the *current* knot name before storing — see
236 // `Stitch::was`'s doc), matching `stitch.name`'s `{knot}.{new_stitch}`
237 // shape, so only labels (never stitches — stitches don't nest) need a
238 // bridge here.
239 for stitch in &manifest.stitches {
240 let Some((old_qualified, _range)) = &stitch.was else {
241 continue;
242 };
243 if old_qualified == &stitch.name {
244 continue;
245 }
246 let new_prefix = format!("{}.", stitch.name);
247 let old_prefix = format!("{old_qualified}.");
248 push_prefix_bridged_aliases(
249 index,
250 module,
251 &manifest.labels,
252 SymbolKind::Label,
253 &new_prefix,
254 &old_prefix,
255 );
256 }
257}
258
259/// For every `descendant` whose qualified name starts with `new_prefix`,
260/// mint an [`brink_format::AliasEntry`] bridging its pre-rename identity
261/// (`old_prefix` substituted for `new_prefix`) to its real, already-current
262/// id. Shared by both [`push_transitive_aliases`] levels (knot→{stitch,
263/// label}, stitch→label) — same substitution, different prefix pair.
264fn push_prefix_bridged_aliases(
265 index: &mut SymbolIndex,
266 module: ModuleCtx<'_>,
267 descendants: &[brink_ir::DeclaredSymbol],
268 kind: SymbolKind,
269 new_prefix: &str,
270 old_prefix: &str,
271) {
272 let tag = kind.definition_tag();
273 for sym in descendants {
274 let Some(rest) = sym.name.strip_prefix(new_prefix) else {
275 continue;
276 };
277 let old_qualified = format!("{old_prefix}{rest}");
278 let old_hash = hash_qualified_name(module.name, &old_qualified, tag);
279 let new_hash = hash_qualified_name(module.name, &sym.name, tag);
280 index.aliases.push(brink_format::AliasEntry {
281 old: DefinitionId::new(tag, old_hash),
282 new: DefinitionId::new(tag, new_hash),
283 });
284 }
285}
286
287/// M-2d import-scoped coexistence (issue #790, decision-log "Cross-module
288/// name collisions" 2026-07-14, endgame clause): two same-name/same-kind
289/// definitions in *different* **declared** modules are no longer a collision
290/// at all — they coexist in the index and import-scoped resolution
291/// (`resolve.rs`, [`crate::ImportScope`]) binds each reference to the module
292/// its file imported. This *relaxes* the M-2c/#793 `E096` stopgap, which had
293/// hard-errored exactly this case as a temporary guard against silent
294/// misbinding while the flat resolver was still authoritative.
295///
296/// Returns `true` when `existing`/`new_module` are such a cross-declared-
297/// module pair (so the caller must let both coexist — no diagnostic, no
298/// skip). A duplicate *within* one declared module, or involving any
299/// undeclared/legacy file, returns `false` — the ordinary inklecate-compat
300/// redefinition warning (`E022`/`E023`/`E026`) still applies and the later
301/// definition is still dropped. The `Dialect::Brink` gate preserves
302/// `strict-ink` byte-for-byte: `#@module` is brink-only syntax, so a
303/// strict-ink story never has a declared module, and this always returns
304/// `false` there — the whole compat corpus keeps its existing behavior.
305///
306/// `is_native` (issue #1562 review finding) widens the gate exactly as
307/// [`crate::strict_diagnostics`]'s own `is_native` widens `E064`'s dialect
308/// check: `dialect` is an ink-only axis — a native `.brink` project has no
309/// dialect to be wrong about (every `.brink` module is always *declared*,
310/// see `brink_db::modules::native_module_path`), so M-2d coexistence must
311/// not depend on a client having declared `dialect: "brink"` in
312/// `initializationOptions`. `true` lets cross-declared-module homonyms
313/// coexist regardless of `dialect`'s value; `false` (every pure-API caller
314/// with no `Language` classification of its own) is byte-identical to
315/// before this parameter existed.
316fn is_cross_declared_module_collision(
317 dialect: crate::Dialect,
318 is_native: bool,
319 existing_module: Option<&str>,
320 new_module: Option<&str>,
321) -> bool {
322 if dialect != crate::Dialect::Brink && !is_native {
323 return false;
324 }
325 matches!(
326 (existing_module, new_module),
327 (Some(a), Some(b)) if a != b
328 )
329}
330
331#[expect(
332 clippy::too_many_arguments,
333 reason = "internal helper threading file/module context + the per-kind duplicate code + dialect through one insertion; splitting further would just re-bundle these into an ad-hoc struct for no clarity gain"
334)]
335fn insert_symbol(
336 index: &mut SymbolIndex,
337 diagnostics: &mut Vec<Diagnostic>,
338 file: FileId,
339 module: ModuleCtx<'_>,
340 sym: &brink_ir::DeclaredSymbol,
341 kind: SymbolKind,
342 dup_code: DiagnosticCode,
343 dialect: crate::Dialect,
344 is_native: bool,
345) {
346 // Duplicate handling. A same-name/same-kind definition already in the
347 // index is normally an inklecate-compat redefinition: we warn and drop
348 // the later one. The M-2d exception (issue #790): if *every* existing
349 // same-kind definition sits in a **different declared module** from this
350 // one, they are not duplicates at all — they coexist and import-scoped
351 // resolution disambiguates them per referring file. Only a *true*
352 // duplicate (same declared module, or the undeclared/legacy world, or
353 // strict-ink where declared modules cannot exist) still warns-and-skips.
354 if let Some(existing_ids) = index.by_name.get(&sym.name) {
355 let true_duplicate = existing_ids
356 .iter()
357 .filter_map(|id| index.symbols.get(id))
358 .filter(|info| info.kind == kind)
359 .find(|existing| {
360 !is_cross_declared_module_collision(
361 dialect,
362 is_native,
363 existing.module.as_deref(),
364 module.name,
365 )
366 });
367 if let Some(_existing) = true_duplicate {
368 diagnostics.push(Diagnostic {
369 file,
370 range: sym.range,
371 message: format!("{}: `{}`", dup_code.title(), sym.name),
372 code: dup_code,
373 });
374 return;
375 }
376 // Otherwise: only cross-declared-module homonyms exist — fall through
377 // and insert this definition alongside them.
378 }
379
380 let tag = kind.definition_tag();
381 let hash = hash_qualified_name(module.name, &sym.name, tag);
382 let id = DefinitionId::new(tag, hash);
383
384 // Effective visibility (declaration-flips-default, modules-spec §4): a
385 // *declared* module (`module.name.is_some()`) defaults private; an
386 // undeclared stem-module defaults public. A `#@private`/`#@public`
387 // override flips that; restating the default is a redundant-override
388 // warning (`E092`).
389 let declared = module.name.is_some();
390 let (visibility, redundant) = effective_visibility(declared, sym.visibility);
391 if redundant {
392 diagnostics.push(Diagnostic {
393 file,
394 range: sym.range,
395 message: format!("{}: `{}`", DiagnosticCode::E092.title(), sym.name),
396 code: DiagnosticCode::E092,
397 });
398 }
399
400 index.symbols.insert(
401 id,
402 SymbolInfo {
403 kind,
404 file,
405 range: sym.range,
406 id,
407 name: sym.name.clone(),
408 params: sym.params.clone(),
409 detail: sym.detail.clone(),
410 scope: None,
411 param_detail: None,
412 module: module.name.map(str::to_string),
413 visibility,
414 },
415 );
416 index.by_name.entry(sym.name.clone()).or_default().push(id);
417
418 // M-3 (docs/modules-spec.md §5): compiled alias-table entries. Additive
419 // and independent — a definition-level `#@was` and a module-level
420 // `#@was` on the same symbol both produce an entry (each aliasing a
421 // *different* stale identity to the same current `id`); the rarer case
422 // of both renamed **simultaneously** (old module + old name together)
423 // is a known gap, not covered by either entry alone.
424 if let Some((old_name, _range)) = &sym.was {
425 let old_hash = hash_qualified_name(module.name, old_name, tag);
426 index.aliases.push(brink_format::AliasEntry {
427 old: DefinitionId::new(tag, old_hash),
428 new: id,
429 });
430 }
431 if let Some(old_module) = module.was {
432 let old_hash = hash_qualified_name(Some(old_module), &sym.name, tag);
433 index.aliases.push(brink_format::AliasEntry {
434 old: DefinitionId::new(tag, old_hash),
435 new: id,
436 });
437 }
438
439 // Warn if the symbol name shadows a built-in function — the classic
440 // uppercase ink intrinsics (`is_builtin_function`) or a T1b stdlib
441 // slice 1 / TM-3-completion lowercase free function
442 // (`is_t1b_stdlib_name`, docs/t1b-surface-spec.md §5 +
443 // docs/typed-mode-spec.md §4 — "an author-defined function with the
444 // same name shadows the builtin, with a warning diagnostic"). Fired
445 // dialect-agnostically here, same as the existing uppercase check: under
446 // `strict-ink` the T1b names aren't reserved at all, so the warning is
447 // merely informational (harmless, matches existing precedent for the
448 // uppercase set, which also doesn't consult dialect).
449 if matches!(
450 kind,
451 SymbolKind::Knot | SymbolKind::Variable | SymbolKind::Constant | SymbolKind::External
452 ) && (crate::resolve::is_builtin_function(&sym.name)
453 || crate::resolve::is_t1b_stdlib_name(&sym.name)
454 // NS-A1: `none` is the Option absence literal (variable-position,
455 // not a call name, so it lives outside `is_t1b_stdlib_name`) —
456 // shadowing it warns exactly like the call-form builtins.
457 || sym.name == "none")
458 {
459 diagnostics.push(Diagnostic {
460 file,
461 range: sym.range,
462 message: format!("{}: `{}`", DiagnosticCode::E035.title(), sym.name),
463 code: DiagnosticCode::E035,
464 });
465 }
466}
467
468fn insert_local(index: &mut SymbolIndex, file: FileId, local: &LocalSymbol) {
469 let id = local_definition_id(&local.scope, &local.name, local.kind);
470
471 index.symbols.insert(
472 id,
473 SymbolInfo {
474 kind: local.kind,
475 file,
476 range: local.range,
477 id,
478 name: local.name.clone(),
479 params: Vec::new(),
480 detail: None,
481 scope: Some(local.scope.clone()),
482 param_detail: local.param_detail.clone(),
483 // Locals are never module-qualified and always module-internal.
484 module: None,
485 visibility: Visibility::Private,
486 },
487 );
488 index
489 .by_name
490 .entry(local.name.clone())
491 .or_default()
492 .push(id);
493}
494
495/// Hash a bare (unqualified) symbol name. Equivalent to
496/// `hash_qualified_name(None, name, tag)` — retained for the local-var
497/// path, whose scope-qualified names are never module-qualified in M-1.
498fn hash_name(name: &str, tag: DefinitionTag) -> u64 {
499 hash_qualified_name(None, name, tag)
500}
501
502/// Hash a symbol name, optionally qualified by its **declared** module
503/// (M-1, docs/modules-spec.md §5).
504///
505/// The identity gate: `hash_qualified_name(None, name, tag)` writes the
506/// hasher in the exact order the pre-modules `hash_name` did — `tag` then
507/// `name` — so every undeclared stem-module symbol hashes to a
508/// byte-identical `DefinitionId`. A declared module folds its name in
509/// *before* the symbol name; `str`'s `Hash` impl self-delimits (a `0xff`
510/// sentinel after the bytes), so `(module="ab", name="c")` can never
511/// collide with `(module="a", name="bc")`.
512fn hash_qualified_name(module: Option<&str>, name: &str, tag: DefinitionTag) -> u64 {
513 let mut hasher = DefaultHasher::new();
514 tag.hash(&mut hasher);
515 if let Some(module) = module {
516 module.hash(&mut hasher);
517 }
518 name.hash(&mut hasher);
519 hasher.finish()
520}
521
522/// Compute the `DefinitionId` for a scoped local (param/temp).
523///
524/// Scope-qualifies the hash so identically-named locals in different
525/// containers get distinct ids: `knot.stitch.name`, `knot.name`, or bare
526/// `name` for an unscoped local. Shared between [`insert_local`] (project-wide
527/// merge, still used by `symbol_index`/hover/completion) and the per-file
528/// local lookup in `resolve.rs` (issue #517) so both derive the identical id
529/// for the same declaration.
530///
531/// Not file-qualified: two files that (pathologically) declare the same
532/// scope-qualified local name still collide on one `DefinitionId` in the
533/// *merged* index (slice-A finding 4, unchanged by this — a merged-index
534/// consumer, e.g. hover, can only show one). Per-file resolution
535/// (`resolve_file`) no longer goes through the merged index for locals, so
536/// the collision cannot leak into resolution correctness.
537pub(crate) fn local_definition_id(scope: &Scope, name: &str, kind: SymbolKind) -> DefinitionId {
538 let tag = kind.definition_tag();
539 let scope_prefix = match (&scope.knot, &scope.stitch) {
540 (Some(k), Some(s)) => format!("{k}.{s}."),
541 (Some(k), None) => format!("{k}."),
542 _ => String::new(),
543 };
544 let qualified = format!("{scope_prefix}{name}");
545 let hash = hash_name(&qualified, tag);
546 DefinitionId::new(tag, hash)
547}
548
549#[cfg(test)]
550#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
551mod tests {
552 use brink_format::{DefinitionId, DefinitionTag};
553 use brink_ir::{DeclaredSymbol, DiagnosticCode, FileId, SymbolManifest};
554 use rowan::{TextRange, TextSize};
555
556 use super::{
557 ModuleMap, ResolvedModule, hash_qualified_name, merge_manifests,
558 merge_manifests_with_modules,
559 };
560
561 fn range(offset: u32, len: u32) -> TextRange {
562 TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
563 }
564
565 fn sym(name: &str, offset: u32) -> DeclaredSymbol {
566 DeclaredSymbol {
567 name: name.to_string(),
568 range: range(offset, name.len() as u32),
569 params: Vec::new(),
570 detail: None,
571 visibility: None,
572 was: None,
573 }
574 }
575
576 /// A declared symbol carrying a `#@was(old_name)` record — `old_name` is
577 /// exactly what `DeclaredSymbol::was` stores (bare for a knot, already
578 /// knot-qualified for a stitch — see `Stitch::was`'s doc).
579 fn sym_with_was(name: &str, offset: u32, old_name: &str) -> DeclaredSymbol {
580 DeclaredSymbol {
581 was: Some((old_name.to_string(), range(offset, old_name.len() as u32))),
582 ..sym(name, offset)
583 }
584 }
585
586 fn bridge(old_name: &str, new_name: &str) -> (DefinitionId, DefinitionId) {
587 let tag = DefinitionTag::Address;
588 (
589 DefinitionId::new(tag, hash_qualified_name(None, old_name, tag)),
590 DefinitionId::new(tag, hash_qualified_name(None, new_name, tag)),
591 )
592 }
593
594 #[test]
595 fn duplicate_knot_emits_e022() {
596 let mut m1 = SymbolManifest::default();
597 m1.knots.push(sym("start", 0));
598
599 let mut m2 = SymbolManifest::default();
600 m2.knots.push(sym("start", 100));
601
602 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
603 let (_index, diags) = merge_manifests(&files);
604
605 assert_eq!(diags.len(), 1);
606 assert_eq!(diags[0].code, DiagnosticCode::E022);
607 }
608
609 #[test]
610 fn duplicate_variable_emits_e023() {
611 let mut m1 = SymbolManifest::default();
612 m1.variables.push(sym("score", 0));
613
614 let mut m2 = SymbolManifest::default();
615 m2.variables.push(sym("score", 100));
616
617 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
618 let (_index, diags) = merge_manifests(&files);
619
620 assert_eq!(diags.len(), 1);
621 assert_eq!(diags[0].code, DiagnosticCode::E023);
622 }
623
624 #[test]
625 fn different_kind_same_name_no_warning() {
626 let mut manifest = SymbolManifest::default();
627 manifest.knots.push(sym("thing", 0));
628 manifest.variables.push(sym("thing", 100));
629
630 let files = vec![(FileId(0), &manifest)];
631 let (_index, diags) = merge_manifests(&files);
632
633 // A knot and a variable with the same name are different kinds — no duplicate.
634 assert!(diags.is_empty(), "expected no diagnostics: {diags:?}");
635 }
636
637 #[test]
638 fn builtin_name_shadow_emits_e035() {
639 let mut manifest = SymbolManifest::default();
640 manifest.knots.push(sym("RANDOM", 0));
641
642 let files = vec![(FileId(0), &manifest)];
643 let (_index, diags) = merge_manifests(&files);
644
645 assert_eq!(diags.len(), 1);
646 assert_eq!(diags[0].code, DiagnosticCode::E035);
647 }
648
649 #[test]
650 fn non_builtin_name_no_shadow_warning() {
651 let mut manifest = SymbolManifest::default();
652 manifest.knots.push(sym("my_function", 0));
653
654 let files = vec![(FileId(0), &manifest)];
655 let (_index, diags) = merge_manifests(&files);
656
657 assert!(diags.is_empty(), "expected no diagnostics: {diags:?}");
658 }
659
660 // ── M-2d cross-module coexistence (issue #790, decision-log
661 // "Cross-module name collisions" 2026-07-14, endgame clause —
662 // relaxes the #784/#793 E096 stopgap) ─────────────────────────────
663
664 /// Two *declared* modules (different names) each defining `start` now
665 /// **coexist** under `Dialect::Brink`: no diagnostic, and *both*
666 /// definitions land in the index under the shared bare name so
667 /// import-scoped resolution can bind each referring file to the module
668 /// it imported. This is the E096 relaxation — the case #793 hard-errored
669 /// now compiles.
670 #[test]
671 fn cross_declared_module_duplicate_coexists_under_brink() {
672 let mut m1 = SymbolManifest::default();
673 m1.knots.push(sym("start", 0));
674 let mut m2 = SymbolManifest::default();
675 m2.knots.push(sym("start", 100));
676
677 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
678 let mut modules = ModuleMap::new();
679 modules.insert(
680 FileId(0),
681 ResolvedModule {
682 name: "quest".to_string(),
683 declared: true,
684 was: None,
685 },
686 );
687 modules.insert(
688 FileId(1),
689 ResolvedModule {
690 name: "town".to_string(),
691 declared: true,
692 was: None,
693 },
694 );
695
696 let (index, diags) =
697 merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
698
699 assert!(
700 diags.is_empty(),
701 "cross-declared-module homonyms coexist with no diagnostic (E096 relaxed), got {diags:?}"
702 );
703
704 // Both `start` definitions survive in the index under the shared bare
705 // name — the raw material import-scoped resolution disambiguates.
706 assert_eq!(index.by_name.get("start").map(Vec::len), Some(2));
707 // …and they are genuinely distinct ids (module-qualified identity).
708 let ids = index.by_name.get("start").expect("both starts present");
709 assert_ne!(
710 ids[0], ids[1],
711 "the two modules' `start`s have distinct ids"
712 );
713 let modules_of: std::collections::BTreeSet<Option<&str>> = ids
714 .iter()
715 .filter_map(|id| index.symbols.get(id))
716 .map(|info| info.module.as_deref())
717 .collect();
718 assert_eq!(
719 modules_of,
720 [Some("quest"), Some("town")].into_iter().collect(),
721 "one `start` per declared module"
722 );
723 }
724
725 /// Two files sharing the **same** declared module keep the ordinary
726 /// within-module `E022` warning — never `E096` — even under
727 /// `Dialect::Brink`.
728 #[test]
729 fn same_declared_module_duplicate_stays_e022_under_brink() {
730 let mut m1 = SymbolManifest::default();
731 m1.knots.push(sym("start", 0));
732 let mut m2 = SymbolManifest::default();
733 m2.knots.push(sym("start", 100));
734
735 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
736 let mut modules = ModuleMap::new();
737 for file in [FileId(0), FileId(1)] {
738 modules.insert(
739 file,
740 ResolvedModule {
741 name: "quest".to_string(),
742 declared: true,
743 was: None,
744 },
745 );
746 }
747
748 let (_index, diags) =
749 merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
750
751 assert_eq!(diags.len(), 1);
752 assert_eq!(diags[0].code, DiagnosticCode::E022);
753 }
754
755 /// A declared-module/undeclared-file collision (one side has no
756 /// declared module) stays `E022` — the escalation requires *both*
757 /// sides to be declared modules.
758 #[test]
759 fn declared_vs_undeclared_duplicate_stays_e022_under_brink() {
760 let mut m1 = SymbolManifest::default();
761 m1.knots.push(sym("start", 0));
762 let mut m2 = SymbolManifest::default();
763 m2.knots.push(sym("start", 100));
764
765 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
766 let mut modules = ModuleMap::new();
767 modules.insert(
768 FileId(0),
769 ResolvedModule {
770 name: "quest".to_string(),
771 declared: true,
772 was: None,
773 },
774 );
775 // FileId(1) absent from `modules` -> undeclared, hashes bare.
776
777 let (_index, diags) =
778 merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
779
780 assert_eq!(diags.len(), 1);
781 assert_eq!(diags[0].code, DiagnosticCode::E022);
782 }
783
784 /// Under `Dialect::StrictInk` (the default) with `is_native = false`, a
785 /// cross-declared-module duplicate never escalates — `merge_manifests`'s
786 /// inert default keeps the compat corpus untouched. See
787 /// `cross_declared_module_duplicate_coexists_under_strict_ink_when_native`
788 /// below for the native counterpart (issue #1562 review finding), where
789 /// the same dialect instead coexists.
790 #[test]
791 fn cross_declared_module_duplicate_stays_e022_under_strict_ink() {
792 let mut m1 = SymbolManifest::default();
793 m1.knots.push(sym("start", 0));
794 let mut m2 = SymbolManifest::default();
795 m2.knots.push(sym("start", 100));
796
797 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
798 let mut modules = ModuleMap::new();
799 modules.insert(
800 FileId(0),
801 ResolvedModule {
802 name: "quest".to_string(),
803 declared: true,
804 was: None,
805 },
806 );
807 modules.insert(
808 FileId(1),
809 ResolvedModule {
810 name: "town".to_string(),
811 declared: true,
812 was: None,
813 },
814 );
815
816 let (_index, diags) =
817 merge_manifests_with_modules(&files, &modules, crate::Dialect::StrictInk, false);
818
819 assert_eq!(diags.len(), 1);
820 assert_eq!(diags[0].code, DiagnosticCode::E022);
821 }
822
823 /// Native counterpart of `cross_declared_module_duplicate_stays_e022_under_strict_ink`
824 /// above (issue #1562 review finding): under the exact same
825 /// `Dialect::StrictInk` (the default a client that never declares
826 /// `initializationOptions.dialect` gets), `is_native = true` still lets
827 /// the two declared-module `start`s coexist — a native `.brink` project
828 /// has no dialect to be wrong about, so M-2d coexistence must not depend
829 /// on a client having declared `dialect: "brink"`.
830 #[test]
831 fn cross_declared_module_duplicate_coexists_under_strict_ink_when_native() {
832 let mut m1 = SymbolManifest::default();
833 m1.knots.push(sym("start", 0));
834 let mut m2 = SymbolManifest::default();
835 m2.knots.push(sym("start", 100));
836
837 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
838 let mut modules = ModuleMap::new();
839 modules.insert(
840 FileId(0),
841 ResolvedModule {
842 name: "quest".to_string(),
843 declared: true,
844 was: None,
845 },
846 );
847 modules.insert(
848 FileId(1),
849 ResolvedModule {
850 name: "town".to_string(),
851 declared: true,
852 was: None,
853 },
854 );
855
856 let (index, diags) =
857 merge_manifests_with_modules(&files, &modules, crate::Dialect::StrictInk, true);
858
859 assert!(
860 diags.is_empty(),
861 "native cross-declared-module homonyms coexist regardless of dialect, got {diags:?}"
862 );
863 assert_eq!(index.by_name.get("start").map(Vec::len), Some(2));
864 }
865
866 // ── M-1 identity gate (docs/modules-spec.md §5) ──────────────────
867
868 fn sample_manifest() -> SymbolManifest {
869 let mut m = SymbolManifest::default();
870 m.knots.push(sym("start", 0));
871 m.stitches.push(sym("start.middle", 20));
872 m.variables.push(sym("score", 60));
873 m.lists.push(sym("colors", 90));
874 m.list_items.push(sym("colors.red", 110));
875 m
876 }
877
878 /// THE critical gate: an undeclared stem-module (the entire pre-modules
879 /// corpus) must hash to byte-identical `DefinitionId`s. Proven two ways:
880 /// (1) qualifying with an *undeclared* module is equivalent to no
881 /// qualification, and (2) the merge is bit-for-bit equal to the
882 /// pre-modules `merge_manifests`.
883 #[test]
884 fn undeclared_module_definition_ids_are_byte_identical() {
885 let manifest = sample_manifest();
886 let files = vec![(FileId(0), &manifest)];
887
888 // (1) Baseline — the pre-modules derivation (no module map).
889 let (baseline, _) = merge_manifests(&files);
890
891 // (2) An *undeclared* stem-module entry must not qualify.
892 let mut modules = ModuleMap::new();
893 modules.insert(
894 FileId(0),
895 ResolvedModule {
896 name: "story".to_string(),
897 declared: false,
898 was: None,
899 },
900 );
901 let (with_undeclared, _) =
902 merge_manifests_with_modules(&files, &modules, crate::Dialect::default(), false);
903
904 // (3) A file absent from the map must also stay bare.
905 let (with_empty, _) = merge_manifests_with_modules(
906 &files,
907 &ModuleMap::new(),
908 crate::Dialect::default(),
909 false,
910 );
911
912 let mut base_ids: Vec<_> = baseline.symbols.keys().map(|id| id.to_raw()).collect();
913 base_ids.sort_unstable();
914 for other in [&with_undeclared, &with_empty] {
915 let mut ids: Vec<_> = other.symbols.keys().map(|id| id.to_raw()).collect();
916 ids.sort_unstable();
917 assert_eq!(
918 ids, base_ids,
919 "undeclared / absent module must produce byte-identical DefinitionIds"
920 );
921 }
922 }
923
924 /// Known-good pinned ids: hardcoded raw `DefinitionId`s for the sample
925 /// symbols under the bare (pre-modules) scheme. If any hashing input or
926 /// order ever changes, these literals break — the tripwire that would
927 /// have caught a silent regeneration of every checked-in `.inkb`.
928 #[test]
929 fn known_good_bare_definition_ids() {
930 let manifest = sample_manifest();
931 let files = vec![(FileId(0), &manifest)];
932 let (index, _) = merge_manifests(&files);
933
934 let id_of = |name: &str| -> u64 {
935 index
936 .by_name
937 .get(name)
938 .and_then(|ids| ids.first())
939 .map(|id| id.to_raw())
940 .expect("symbol present")
941 };
942
943 // These are the raw ids produced by `hash(tag, name)` — the exact
944 // derivation checked-in artifacts and saved games depend on. If any
945 // hashing input or write order regresses, these literals break.
946 assert_eq!(id_of("start"), 0x01e2_25d7_2013_19eb);
947 assert_eq!(id_of("start.middle"), 0x015d_6dc8_e16e_aef6);
948 assert_eq!(id_of("score"), 0x0293_4ea0_c935_6d8d);
949 assert_eq!(id_of("colors"), 0x03ab_176e_5431_d3b4);
950 assert_eq!(id_of("colors.red"), 0x04c5_e205_8cad_67b3);
951 }
952
953 /// A *declared* module qualifies identity — its ids differ from bare.
954 #[test]
955 fn declared_module_changes_definition_ids() {
956 let manifest = sample_manifest();
957 let files = vec![(FileId(0), &manifest)];
958 let (bare, _) = merge_manifests(&files);
959
960 let mut modules = ModuleMap::new();
961 modules.insert(
962 FileId(0),
963 ResolvedModule {
964 name: "quest".to_string(),
965 declared: true,
966 was: None,
967 },
968 );
969 let (qualified, _) =
970 merge_manifests_with_modules(&files, &modules, crate::Dialect::default(), false);
971
972 let bare_start = bare.by_name.get("start").and_then(|v| v.first()).copied();
973 let q_start = qualified
974 .by_name
975 .get("start")
976 .and_then(|v| v.first())
977 .copied();
978 assert!(bare_start.is_some() && q_start.is_some());
979 assert_ne!(
980 bare_start, q_start,
981 "a declared module must qualify (change) the DefinitionId"
982 );
983 // Same tag byte though — only the hash portion moves.
984 assert_eq!(
985 bare_start.unwrap().tag(),
986 q_start.unwrap().tag(),
987 "qualification changes the hash, not the tag"
988 );
989 }
990
991 // ── M-3 transitive aliases (issue #1671, docs/modules-spec.md §5)
992 // ─────────────────────────────────────────────────────────────────
993
994 /// `#@was` on a knot mints the knot's own alias entry *and* one per
995 /// descendant re-keyed only because the knot's name changed — a stitch
996 /// and a label nested directly under the knot, in this case.
997 #[test]
998 fn knot_rename_transitively_aliases_stitch_and_label() {
999 let mut manifest = SymbolManifest::default();
1000 manifest.knots.push(sym_with_was("plaza", 0, "hub"));
1001 manifest.stitches.push(sym("plaza.market", 10));
1002 manifest.labels.push(sym("plaza.done", 30));
1003
1004 let files = vec![(FileId(0), &manifest)];
1005 let (index, _diags) = merge_manifests(&files);
1006
1007 let expected = [
1008 bridge("hub", "plaza"),
1009 bridge("hub.market", "plaza.market"),
1010 bridge("hub.done", "plaza.done"),
1011 ];
1012 for (old, new) in expected {
1013 assert!(
1014 index.aliases.iter().any(|a| a.old == old && a.new == new),
1015 "missing bridge {old:?} -> {new:?}; aliases={:?}",
1016 index.aliases
1017 );
1018 }
1019 assert_eq!(
1020 index.aliases.len(),
1021 3,
1022 "one entry for the knot plus one per descendant; aliases={:?}",
1023 index.aliases
1024 );
1025 }
1026
1027 /// `#@was` on a stitch (unrelated to any knot rename) mints the stitch's
1028 /// own entry plus one for a label nested under it — `Stitch::was` is
1029 /// already qualified with the enclosing (current) knot name, so the
1030 /// bridge lands on `{knot}.{old_stitch}.{label}` ->
1031 /// `{knot}.{new_stitch}.{label}`.
1032 #[test]
1033 fn stitch_rename_transitively_aliases_its_label() {
1034 let mut manifest = SymbolManifest::default();
1035 manifest.knots.push(sym("hub", 0));
1036 manifest
1037 .stitches
1038 .push(sym_with_was("hub.bazaar", 10, "hub.market"));
1039 manifest.labels.push(sym("hub.bazaar.done", 30));
1040
1041 let files = vec![(FileId(0), &manifest)];
1042 let (index, _diags) = merge_manifests(&files);
1043
1044 let expected = [
1045 bridge("hub.market", "hub.bazaar"),
1046 bridge("hub.market.done", "hub.bazaar.done"),
1047 ];
1048 for (old, new) in expected {
1049 assert!(
1050 index.aliases.iter().any(|a| a.old == old && a.new == new),
1051 "missing bridge {old:?} -> {new:?}; aliases={:?}",
1052 index.aliases
1053 );
1054 }
1055 assert_eq!(index.aliases.len(), 2, "aliases={:?}", index.aliases);
1056 }
1057
1058 /// No `#@was` anywhere — no aliases minted at all, transitive or
1059 /// otherwise. (Guards against `push_transitive_aliases` firing on an
1060 /// empty-prefix false positive.)
1061 #[test]
1062 fn no_rename_no_aliases() {
1063 let mut manifest = SymbolManifest::default();
1064 manifest.knots.push(sym("hub", 0));
1065 manifest.stitches.push(sym("hub.market", 10));
1066 manifest.labels.push(sym("hub.market.done", 30));
1067
1068 let files = vec![(FileId(0), &manifest)];
1069 let (index, _diags) = merge_manifests(&files);
1070
1071 assert!(index.aliases.is_empty(), "aliases={:?}", index.aliases);
1072 }
1073}