use std::collections::BTreeMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use brink_format::{DefinitionId, DefinitionTag};
use brink_ir::{
Diagnostic, DiagnosticCode, FileId, LocalSymbol, Scope, SymbolIndex, SymbolInfo, SymbolKind,
SymbolManifest, Visibility, VisibilityMark,
};
fn effective_visibility(declared: bool, mark: Option<VisibilityMark>) -> (Visibility, bool) {
let default = if declared {
Visibility::Private
} else {
Visibility::Public
};
match mark {
None => (default, false),
Some(VisibilityMark::Private) => (Visibility::Private, default == Visibility::Private),
Some(VisibilityMark::Public) => (Visibility::Public, default == Visibility::Public),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedModule {
pub name: String,
pub declared: bool,
pub was: Option<String>,
}
pub type ModuleMap = BTreeMap<FileId, ResolvedModule>;
pub fn merge_manifests(files: &[(FileId, &SymbolManifest)]) -> (SymbolIndex, Vec<Diagnostic>) {
merge_manifests_with_modules(files, &ModuleMap::new(), crate::Dialect::default(), false)
}
pub fn merge_manifests_with_modules(
files: &[(FileId, &SymbolManifest)],
modules: &ModuleMap,
dialect: crate::Dialect,
is_native: bool,
) -> (SymbolIndex, Vec<Diagnostic>) {
let mut index = SymbolIndex::default();
let mut diagnostics = Vec::new();
for &(file_id, manifest) in files {
let resolved = modules.get(&file_id).filter(|m| m.declared);
let module = ModuleCtx {
name: resolved.map(|m| m.name.as_str()),
was: resolved.and_then(|m| m.was.as_deref()),
};
insert_file_symbols(
&mut index,
&mut diagnostics,
file_id,
module,
manifest,
dialect,
is_native,
);
}
(index, diagnostics)
}
#[derive(Clone, Copy)]
struct ModuleCtx<'a> {
name: Option<&'a str>,
was: Option<&'a str>,
}
fn insert_file_symbols(
index: &mut SymbolIndex,
diagnostics: &mut Vec<Diagnostic>,
file_id: FileId,
module: ModuleCtx<'_>,
manifest: &SymbolManifest,
dialect: crate::Dialect,
is_native: bool,
) {
use DiagnosticCode::{E022, E023, E026};
use SymbolKind::{Constant, External, Knot, Label, List, ListItem, Stitch, Struct, Variable};
let groups: [(&[brink_ir::DeclaredSymbol], SymbolKind, DiagnosticCode); 9] = [
(&manifest.knots, Knot, E022),
(&manifest.stitches, Stitch, E022),
(&manifest.variables, Variable, E023),
(&manifest.constants, Constant, E023),
(&manifest.lists, List, E023),
(&manifest.structs, Struct, E023),
(&manifest.externals, External, E023),
(&manifest.labels, Label, E022),
(&manifest.list_items, ListItem, E026),
];
for (syms, kind, dup_code) in groups {
for sym in syms {
insert_symbol(
index,
diagnostics,
file_id,
module,
sym,
kind,
dup_code,
dialect,
is_native,
);
}
}
for local in &manifest.locals {
insert_local(index, file_id, local);
}
push_transitive_aliases(index, module, manifest);
}
fn push_transitive_aliases(
index: &mut SymbolIndex,
module: ModuleCtx<'_>,
manifest: &SymbolManifest,
) {
for knot in &manifest.knots {
let Some((old_name, _range)) = &knot.was else {
continue;
};
if old_name == &knot.name {
continue; }
let new_prefix = format!("{}.", knot.name);
let old_prefix = format!("{old_name}.");
for (descendants, kind) in [
(&manifest.stitches, SymbolKind::Stitch),
(&manifest.labels, SymbolKind::Label),
] {
push_prefix_bridged_aliases(index, module, descendants, kind, &new_prefix, &old_prefix);
}
}
for stitch in &manifest.stitches {
let Some((old_qualified, _range)) = &stitch.was else {
continue;
};
if old_qualified == &stitch.name {
continue;
}
let new_prefix = format!("{}.", stitch.name);
let old_prefix = format!("{old_qualified}.");
push_prefix_bridged_aliases(
index,
module,
&manifest.labels,
SymbolKind::Label,
&new_prefix,
&old_prefix,
);
}
}
fn push_prefix_bridged_aliases(
index: &mut SymbolIndex,
module: ModuleCtx<'_>,
descendants: &[brink_ir::DeclaredSymbol],
kind: SymbolKind,
new_prefix: &str,
old_prefix: &str,
) {
let tag = kind.definition_tag();
for sym in descendants {
let Some(rest) = sym.name.strip_prefix(new_prefix) else {
continue;
};
let old_qualified = format!("{old_prefix}{rest}");
let old_hash = hash_qualified_name(module.name, &old_qualified, tag);
let new_hash = hash_qualified_name(module.name, &sym.name, tag);
index.aliases.push(brink_format::AliasEntry {
old: DefinitionId::new(tag, old_hash),
new: DefinitionId::new(tag, new_hash),
});
}
}
fn is_cross_declared_module_collision(
dialect: crate::Dialect,
is_native: bool,
existing_module: Option<&str>,
new_module: Option<&str>,
) -> bool {
if dialect != crate::Dialect::Brink && !is_native {
return false;
}
matches!(
(existing_module, new_module),
(Some(a), Some(b)) if a != b
)
}
#[expect(
clippy::too_many_arguments,
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"
)]
fn insert_symbol(
index: &mut SymbolIndex,
diagnostics: &mut Vec<Diagnostic>,
file: FileId,
module: ModuleCtx<'_>,
sym: &brink_ir::DeclaredSymbol,
kind: SymbolKind,
dup_code: DiagnosticCode,
dialect: crate::Dialect,
is_native: bool,
) {
if let Some(existing_ids) = index.by_name.get(&sym.name) {
let true_duplicate = existing_ids
.iter()
.filter_map(|id| index.symbols.get(id))
.filter(|info| info.kind == kind)
.find(|existing| {
!is_cross_declared_module_collision(
dialect,
is_native,
existing.module.as_deref(),
module.name,
)
});
if let Some(_existing) = true_duplicate {
diagnostics.push(Diagnostic {
file,
range: sym.range,
message: format!("{}: `{}`", dup_code.title(), sym.name),
code: dup_code,
});
return;
}
}
let tag = kind.definition_tag();
let hash = hash_qualified_name(module.name, &sym.name, tag);
let id = DefinitionId::new(tag, hash);
let declared = module.name.is_some();
let (visibility, redundant) = effective_visibility(declared, sym.visibility);
if redundant {
diagnostics.push(Diagnostic {
file,
range: sym.range,
message: format!("{}: `{}`", DiagnosticCode::E092.title(), sym.name),
code: DiagnosticCode::E092,
});
}
index.symbols.insert(
id,
SymbolInfo {
kind,
file,
range: sym.range,
id,
name: sym.name.clone(),
params: sym.params.clone(),
detail: sym.detail.clone(),
scope: None,
param_detail: None,
module: module.name.map(str::to_string),
visibility,
},
);
index.by_name.entry(sym.name.clone()).or_default().push(id);
if let Some((old_name, _range)) = &sym.was {
let old_hash = hash_qualified_name(module.name, old_name, tag);
index.aliases.push(brink_format::AliasEntry {
old: DefinitionId::new(tag, old_hash),
new: id,
});
}
if let Some(old_module) = module.was {
let old_hash = hash_qualified_name(Some(old_module), &sym.name, tag);
index.aliases.push(brink_format::AliasEntry {
old: DefinitionId::new(tag, old_hash),
new: id,
});
}
if matches!(
kind,
SymbolKind::Knot | SymbolKind::Variable | SymbolKind::Constant | SymbolKind::External
) && (crate::resolve::is_builtin_function(&sym.name)
|| crate::resolve::is_t1b_stdlib_name(&sym.name)
|| sym.name == "none")
{
diagnostics.push(Diagnostic {
file,
range: sym.range,
message: format!("{}: `{}`", DiagnosticCode::E035.title(), sym.name),
code: DiagnosticCode::E035,
});
}
}
fn insert_local(index: &mut SymbolIndex, file: FileId, local: &LocalSymbol) {
let id = local_definition_id(&local.scope, &local.name, local.kind);
index.symbols.insert(
id,
SymbolInfo {
kind: local.kind,
file,
range: local.range,
id,
name: local.name.clone(),
params: Vec::new(),
detail: None,
scope: Some(local.scope.clone()),
param_detail: local.param_detail.clone(),
module: None,
visibility: Visibility::Private,
},
);
index
.by_name
.entry(local.name.clone())
.or_default()
.push(id);
}
fn hash_name(name: &str, tag: DefinitionTag) -> u64 {
hash_qualified_name(None, name, tag)
}
fn hash_qualified_name(module: Option<&str>, name: &str, tag: DefinitionTag) -> u64 {
let mut hasher = DefaultHasher::new();
tag.hash(&mut hasher);
if let Some(module) = module {
module.hash(&mut hasher);
}
name.hash(&mut hasher);
hasher.finish()
}
pub(crate) fn local_definition_id(scope: &Scope, name: &str, kind: SymbolKind) -> DefinitionId {
let tag = kind.definition_tag();
let scope_prefix = match (&scope.knot, &scope.stitch) {
(Some(k), Some(s)) => format!("{k}.{s}."),
(Some(k), None) => format!("{k}."),
_ => String::new(),
};
let qualified = format!("{scope_prefix}{name}");
let hash = hash_name(&qualified, tag);
DefinitionId::new(tag, hash)
}
#[cfg(test)]
#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
mod tests {
use brink_format::{DefinitionId, DefinitionTag};
use brink_ir::{DeclaredSymbol, DiagnosticCode, FileId, SymbolManifest};
use rowan::{TextRange, TextSize};
use super::{
ModuleMap, ResolvedModule, hash_qualified_name, merge_manifests,
merge_manifests_with_modules,
};
fn range(offset: u32, len: u32) -> TextRange {
TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
}
fn sym(name: &str, offset: u32) -> DeclaredSymbol {
DeclaredSymbol {
name: name.to_string(),
range: range(offset, name.len() as u32),
params: Vec::new(),
detail: None,
visibility: None,
was: None,
}
}
fn sym_with_was(name: &str, offset: u32, old_name: &str) -> DeclaredSymbol {
DeclaredSymbol {
was: Some((old_name.to_string(), range(offset, old_name.len() as u32))),
..sym(name, offset)
}
}
fn bridge(old_name: &str, new_name: &str) -> (DefinitionId, DefinitionId) {
let tag = DefinitionTag::Address;
(
DefinitionId::new(tag, hash_qualified_name(None, old_name, tag)),
DefinitionId::new(tag, hash_qualified_name(None, new_name, tag)),
)
}
#[test]
fn duplicate_knot_emits_e022() {
let mut m1 = SymbolManifest::default();
m1.knots.push(sym("start", 0));
let mut m2 = SymbolManifest::default();
m2.knots.push(sym("start", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let (_index, diags) = merge_manifests(&files);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E022);
}
#[test]
fn duplicate_variable_emits_e023() {
let mut m1 = SymbolManifest::default();
m1.variables.push(sym("score", 0));
let mut m2 = SymbolManifest::default();
m2.variables.push(sym("score", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let (_index, diags) = merge_manifests(&files);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E023);
}
#[test]
fn different_kind_same_name_no_warning() {
let mut manifest = SymbolManifest::default();
manifest.knots.push(sym("thing", 0));
manifest.variables.push(sym("thing", 100));
let files = vec![(FileId(0), &manifest)];
let (_index, diags) = merge_manifests(&files);
assert!(diags.is_empty(), "expected no diagnostics: {diags:?}");
}
#[test]
fn builtin_name_shadow_emits_e035() {
let mut manifest = SymbolManifest::default();
manifest.knots.push(sym("RANDOM", 0));
let files = vec![(FileId(0), &manifest)];
let (_index, diags) = merge_manifests(&files);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E035);
}
#[test]
fn non_builtin_name_no_shadow_warning() {
let mut manifest = SymbolManifest::default();
manifest.knots.push(sym("my_function", 0));
let files = vec![(FileId(0), &manifest)];
let (_index, diags) = merge_manifests(&files);
assert!(diags.is_empty(), "expected no diagnostics: {diags:?}");
}
#[test]
fn cross_declared_module_duplicate_coexists_under_brink() {
let mut m1 = SymbolManifest::default();
m1.knots.push(sym("start", 0));
let mut m2 = SymbolManifest::default();
m2.knots.push(sym("start", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let mut modules = ModuleMap::new();
modules.insert(
FileId(0),
ResolvedModule {
name: "quest".to_string(),
declared: true,
was: None,
},
);
modules.insert(
FileId(1),
ResolvedModule {
name: "town".to_string(),
declared: true,
was: None,
},
);
let (index, diags) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
assert!(
diags.is_empty(),
"cross-declared-module homonyms coexist with no diagnostic (E096 relaxed), got {diags:?}"
);
assert_eq!(index.by_name.get("start").map(Vec::len), Some(2));
let ids = index.by_name.get("start").expect("both starts present");
assert_ne!(
ids[0], ids[1],
"the two modules' `start`s have distinct ids"
);
let modules_of: std::collections::BTreeSet<Option<&str>> = ids
.iter()
.filter_map(|id| index.symbols.get(id))
.map(|info| info.module.as_deref())
.collect();
assert_eq!(
modules_of,
[Some("quest"), Some("town")].into_iter().collect(),
"one `start` per declared module"
);
}
#[test]
fn same_declared_module_duplicate_stays_e022_under_brink() {
let mut m1 = SymbolManifest::default();
m1.knots.push(sym("start", 0));
let mut m2 = SymbolManifest::default();
m2.knots.push(sym("start", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let mut modules = ModuleMap::new();
for file in [FileId(0), FileId(1)] {
modules.insert(
file,
ResolvedModule {
name: "quest".to_string(),
declared: true,
was: None,
},
);
}
let (_index, diags) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E022);
}
#[test]
fn declared_vs_undeclared_duplicate_stays_e022_under_brink() {
let mut m1 = SymbolManifest::default();
m1.knots.push(sym("start", 0));
let mut m2 = SymbolManifest::default();
m2.knots.push(sym("start", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let mut modules = ModuleMap::new();
modules.insert(
FileId(0),
ResolvedModule {
name: "quest".to_string(),
declared: true,
was: None,
},
);
let (_index, diags) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::Brink, false);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E022);
}
#[test]
fn cross_declared_module_duplicate_stays_e022_under_strict_ink() {
let mut m1 = SymbolManifest::default();
m1.knots.push(sym("start", 0));
let mut m2 = SymbolManifest::default();
m2.knots.push(sym("start", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let mut modules = ModuleMap::new();
modules.insert(
FileId(0),
ResolvedModule {
name: "quest".to_string(),
declared: true,
was: None,
},
);
modules.insert(
FileId(1),
ResolvedModule {
name: "town".to_string(),
declared: true,
was: None,
},
);
let (_index, diags) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::StrictInk, false);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E022);
}
#[test]
fn cross_declared_module_duplicate_coexists_under_strict_ink_when_native() {
let mut m1 = SymbolManifest::default();
m1.knots.push(sym("start", 0));
let mut m2 = SymbolManifest::default();
m2.knots.push(sym("start", 100));
let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
let mut modules = ModuleMap::new();
modules.insert(
FileId(0),
ResolvedModule {
name: "quest".to_string(),
declared: true,
was: None,
},
);
modules.insert(
FileId(1),
ResolvedModule {
name: "town".to_string(),
declared: true,
was: None,
},
);
let (index, diags) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::StrictInk, true);
assert!(
diags.is_empty(),
"native cross-declared-module homonyms coexist regardless of dialect, got {diags:?}"
);
assert_eq!(index.by_name.get("start").map(Vec::len), Some(2));
}
fn sample_manifest() -> SymbolManifest {
let mut m = SymbolManifest::default();
m.knots.push(sym("start", 0));
m.stitches.push(sym("start.middle", 20));
m.variables.push(sym("score", 60));
m.lists.push(sym("colors", 90));
m.list_items.push(sym("colors.red", 110));
m
}
#[test]
fn undeclared_module_definition_ids_are_byte_identical() {
let manifest = sample_manifest();
let files = vec![(FileId(0), &manifest)];
let (baseline, _) = merge_manifests(&files);
let mut modules = ModuleMap::new();
modules.insert(
FileId(0),
ResolvedModule {
name: "story".to_string(),
declared: false,
was: None,
},
);
let (with_undeclared, _) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::default(), false);
let (with_empty, _) = merge_manifests_with_modules(
&files,
&ModuleMap::new(),
crate::Dialect::default(),
false,
);
let mut base_ids: Vec<_> = baseline.symbols.keys().map(|id| id.to_raw()).collect();
base_ids.sort_unstable();
for other in [&with_undeclared, &with_empty] {
let mut ids: Vec<_> = other.symbols.keys().map(|id| id.to_raw()).collect();
ids.sort_unstable();
assert_eq!(
ids, base_ids,
"undeclared / absent module must produce byte-identical DefinitionIds"
);
}
}
#[test]
fn known_good_bare_definition_ids() {
let manifest = sample_manifest();
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let id_of = |name: &str| -> u64 {
index
.by_name
.get(name)
.and_then(|ids| ids.first())
.map(|id| id.to_raw())
.expect("symbol present")
};
assert_eq!(id_of("start"), 0x01e2_25d7_2013_19eb);
assert_eq!(id_of("start.middle"), 0x015d_6dc8_e16e_aef6);
assert_eq!(id_of("score"), 0x0293_4ea0_c935_6d8d);
assert_eq!(id_of("colors"), 0x03ab_176e_5431_d3b4);
assert_eq!(id_of("colors.red"), 0x04c5_e205_8cad_67b3);
}
#[test]
fn declared_module_changes_definition_ids() {
let manifest = sample_manifest();
let files = vec![(FileId(0), &manifest)];
let (bare, _) = merge_manifests(&files);
let mut modules = ModuleMap::new();
modules.insert(
FileId(0),
ResolvedModule {
name: "quest".to_string(),
declared: true,
was: None,
},
);
let (qualified, _) =
merge_manifests_with_modules(&files, &modules, crate::Dialect::default(), false);
let bare_start = bare.by_name.get("start").and_then(|v| v.first()).copied();
let q_start = qualified
.by_name
.get("start")
.and_then(|v| v.first())
.copied();
assert!(bare_start.is_some() && q_start.is_some());
assert_ne!(
bare_start, q_start,
"a declared module must qualify (change) the DefinitionId"
);
assert_eq!(
bare_start.unwrap().tag(),
q_start.unwrap().tag(),
"qualification changes the hash, not the tag"
);
}
#[test]
fn knot_rename_transitively_aliases_stitch_and_label() {
let mut manifest = SymbolManifest::default();
manifest.knots.push(sym_with_was("plaza", 0, "hub"));
manifest.stitches.push(sym("plaza.market", 10));
manifest.labels.push(sym("plaza.done", 30));
let files = vec![(FileId(0), &manifest)];
let (index, _diags) = merge_manifests(&files);
let expected = [
bridge("hub", "plaza"),
bridge("hub.market", "plaza.market"),
bridge("hub.done", "plaza.done"),
];
for (old, new) in expected {
assert!(
index.aliases.iter().any(|a| a.old == old && a.new == new),
"missing bridge {old:?} -> {new:?}; aliases={:?}",
index.aliases
);
}
assert_eq!(
index.aliases.len(),
3,
"one entry for the knot plus one per descendant; aliases={:?}",
index.aliases
);
}
#[test]
fn stitch_rename_transitively_aliases_its_label() {
let mut manifest = SymbolManifest::default();
manifest.knots.push(sym("hub", 0));
manifest
.stitches
.push(sym_with_was("hub.bazaar", 10, "hub.market"));
manifest.labels.push(sym("hub.bazaar.done", 30));
let files = vec![(FileId(0), &manifest)];
let (index, _diags) = merge_manifests(&files);
let expected = [
bridge("hub.market", "hub.bazaar"),
bridge("hub.market.done", "hub.bazaar.done"),
];
for (old, new) in expected {
assert!(
index.aliases.iter().any(|a| a.old == old && a.new == new),
"missing bridge {old:?} -> {new:?}; aliases={:?}",
index.aliases
);
}
assert_eq!(index.aliases.len(), 2, "aliases={:?}", index.aliases);
}
#[test]
fn no_rename_no_aliases() {
let mut manifest = SymbolManifest::default();
manifest.knots.push(sym("hub", 0));
manifest.stitches.push(sym("hub.market", 10));
manifest.labels.push(sym("hub.market.done", 30));
let files = vec![(FileId(0), &manifest)];
let (index, _diags) = merge_manifests(&files);
assert!(index.aliases.is_empty(), "aliases={:?}", index.aliases);
}
}