use std::collections::{BTreeMap, BTreeSet};
use brink_format::DefinitionId;
use brink_ir::{
Diagnostic, DiagnosticCode, FileId, Import, LocalSymbol, RefKind, ResolutionMap, ResolvedRef,
Scope, SymbolIndex, SymbolInfo, SymbolKind, SymbolManifest, Visibility,
is_reserved_root_module,
};
use crate::manifest::local_definition_id;
#[must_use]
pub(crate) fn import_coverage_for_file(
imports: &[Import],
) -> (BTreeSet<String>, BTreeSet<(&str, &str)>) {
let mut qualified = BTreeSet::new();
let mut bare = BTreeSet::new();
for import in imports {
if import.bare {
for item in &import.items {
bare.insert((import.module.as_str(), item.name.as_str()));
qualified.insert(format!("{}::{}", import.module, item.name));
}
} else {
qualified.insert(import.module.clone());
}
}
(qualified, bare)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ImportScope {
pub file_module: Option<String>,
pub qualified_modules: BTreeSet<String>,
pub bare_imports: BTreeSet<(String, String)>,
pub aliases: BTreeMap<String, (String, String)>,
}
impl ImportScope {
#[must_use]
pub fn new(file_module: Option<String>, imports: &[Import]) -> Self {
let (qualified, bare) = import_coverage_for_file(imports);
let mut aliases = BTreeMap::new();
for import in imports {
if !import.bare {
continue;
}
for item in &import.items {
if let Some(alias) = &item.alias {
aliases.insert(alias.clone(), (import.module.clone(), item.name.clone()));
}
}
}
Self {
file_module,
qualified_modules: qualified,
bare_imports: bare
.into_iter()
.map(|(module, name)| (module.to_string(), name.to_string()))
.collect(),
aliases,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Candidacy {
InScope,
Imported,
Other,
}
fn classify(scope: &ImportScope, info: &SymbolInfo) -> Candidacy {
match &info.module {
None => Candidacy::InScope,
Some(module) => {
if scope.file_module.as_deref() == Some(module.as_str()) {
Candidacy::InScope
} else if info.visibility == Visibility::Public
&& (scope.qualified_modules.contains(module)
|| scope
.bare_imports
.contains(&(module.clone(), info.name.clone())))
{
Candidacy::Imported
} else {
Candidacy::Other
}
}
}
}
#[cfg(test)]
pub fn resolve_refs(
index: &SymbolIndex,
files: &[(FileId, &SymbolManifest)],
) -> (ResolutionMap, Vec<Diagnostic>) {
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
let scope = ImportScope::default();
for &(file_id, manifest) in files {
let (file_map, file_diags) = resolve_file(index, &scope, file_id, manifest);
map.extend(file_map);
diagnostics.extend(file_diags);
}
(map, diagnostics)
}
pub fn resolve_file(
index: &SymbolIndex,
scope: &ImportScope,
file_id: FileId,
manifest: &SymbolManifest,
) -> (ResolutionMap, Vec<Diagnostic>) {
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
let locals = &manifest.locals;
for uref in &manifest.unresolved {
match uref.kind {
RefKind::Divert => {
resolve_divert(
index,
scope,
locals,
file_id,
uref,
&mut map,
&mut diagnostics,
);
}
RefKind::Variable => {
resolve_variable(
index,
scope,
locals,
file_id,
uref,
&mut map,
&mut diagnostics,
);
}
RefKind::Function => {
resolve_function(
index,
scope,
locals,
file_id,
uref,
&mut map,
&mut diagnostics,
);
}
RefKind::List => {
resolve_list_ref(index, scope, file_id, uref, &mut map, &mut diagnostics);
}
RefKind::Struct => {
resolve_struct_ref(index, scope, file_id, uref, &mut map, &mut diagnostics);
}
RefKind::Type => {
resolve_type_ref(index, scope, file_id, uref, &mut map);
}
}
}
(map, diagnostics)
}
fn resolve_divert(
index: &SymbolIndex,
scope: &ImportScope,
locals: &[LocalSymbol],
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
map: &mut ResolutionMap,
diagnostics: &mut Vec<Diagnostic>,
) {
if let Some(id) = lookup_divert(index, scope, locals, uref) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
check_divert_arity(index, file_id, uref, id, diagnostics);
} else {
diagnostics.push(unresolved_diag(
index,
scope,
file_id,
uref.range,
&uref.path,
DiagnosticCode::E024,
&[
SymbolKind::Knot,
SymbolKind::Stitch,
SymbolKind::Label,
SymbolKind::Variable,
SymbolKind::Constant,
],
));
}
}
fn check_divert_arity(
index: &SymbolIndex,
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
target: DefinitionId,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(call_arg_count) = uref.arg_count else {
return;
};
let Some(info) = index.symbols.get(&target) else {
return;
};
if !matches!(
info.kind,
SymbolKind::Knot | SymbolKind::Stitch | SymbolKind::Label
) {
return;
}
let expected = info.params.len();
if call_arg_count != expected {
diagnostics.push(Diagnostic {
file: file_id,
range: uref.range,
message: format!(
"{}: `{}` expects {} argument(s), got {}",
DiagnosticCode::E176.title(),
uref.path,
expected,
call_arg_count,
),
code: DiagnosticCode::E176,
});
}
}
fn lookup_divert(
index: &SymbolIndex,
scope: &ImportScope,
locals: &[LocalSymbol],
uref: &brink_ir::UnresolvedRef,
) -> Option<DefinitionId> {
let path = &uref.path;
if uref.module_qualified {
return lookup_qualified_divert(index, scope, path);
}
if path.contains('.') {
if let Some(id) =
lookup_by_name(index, scope, path, &[SymbolKind::Stitch, SymbolKind::Label])
{
return Some(id);
}
if let Some(knot) = &uref.scope.knot {
let qualified = format!("{knot}.{path}");
if let Some(id) = lookup_by_name(
index,
scope,
&qualified,
&[SymbolKind::Stitch, SymbolKind::Label],
) {
return Some(id);
}
}
return None;
}
if let Some(knot) = &uref.scope.knot {
let qualified = format!("{knot}.{path}");
if let Some(id) = lookup_by_name(
index,
scope,
&qualified,
&[SymbolKind::Stitch, SymbolKind::Label],
) {
return Some(id);
}
if let Some(stitch) = &uref.scope.stitch
&& let Some(id) = lookup_by_name(
index,
scope,
&format!("{knot}.{stitch}.{path}"),
&[SymbolKind::Label],
)
{
return Some(id);
}
}
if let Some(id) = lookup_knot_bare(index, scope, path) {
return Some(id);
}
if let Some(id) =
lookup_bare_excluding_qualified_only(index, scope, path, &[SymbolKind::Stitch])
{
return Some(id);
}
if let Some(knot) = &uref.scope.knot
&& let Some(id) = lookup_label_in_knot(index, scope, knot, path)
{
return Some(id);
}
if let Some(id) = lookup_bare_excluding_qualified_only(index, scope, path, &[SymbolKind::Label])
{
return Some(id);
}
if let Some(id) = lookup_bare_excluding_qualified_only(
index,
scope,
path,
&[SymbolKind::Variable, SymbolKind::Constant],
) {
return Some(id);
}
lookup_local_in_scope(locals, path, &uref.scope)
}
fn is_qualified_import_only(scope: &ImportScope, info: &SymbolInfo) -> bool {
let Some(module) = &info.module else {
return false;
};
info.visibility == Visibility::Public
&& scope.qualified_modules.contains(module)
&& !scope
.bare_imports
.contains(&(module.clone(), info.name.clone()))
}
fn lookup_knot_bare(index: &SymbolIndex, scope: &ImportScope, name: &str) -> Option<DefinitionId> {
lookup_bare_excluding_qualified_only(index, scope, name, &[SymbolKind::Knot])
}
fn lookup_bare_excluding_qualified_only(
index: &SymbolIndex,
scope: &ImportScope,
name: &str,
kinds: &[SymbolKind],
) -> Option<DefinitionId> {
if let Some(id) = lookup_bare_excluding_qualified_only_direct(index, scope, name, kinds) {
return Some(id);
}
let (module, source_name) = scope.aliases.get(name)?;
let ids = index.by_name.get(source_name.as_str())?;
ids.iter().find_map(|id| {
let info = index.symbols.get(id)?;
(kinds.contains(&info.kind) && info.module.as_deref() == Some(module.as_str()))
.then_some(*id)
})
}
fn lookup_bare_excluding_qualified_only_direct(
index: &SymbolIndex,
scope: &ImportScope,
name: &str,
kinds: &[SymbolKind],
) -> Option<DefinitionId> {
let ids = index.by_name.get(name)?;
let mut first_match = None;
let mut first_in_scope = None;
let mut first_imported = None;
let mut multiple = false;
for id in ids {
let Some(info) = index.symbols.get(id) else {
continue;
};
if !kinds.contains(&info.kind) {
continue;
}
let candidacy = classify(scope, info);
if candidacy == Candidacy::Other
&& info.module.as_deref().is_some_and(is_reserved_root_module)
{
continue;
}
if is_qualified_import_only(scope, info) {
continue;
}
if first_match.is_none() {
first_match = Some(*id);
} else {
multiple = true;
}
match candidacy {
Candidacy::InScope if first_in_scope.is_none() => first_in_scope = Some(*id),
Candidacy::Imported if first_imported.is_none() => first_imported = Some(*id),
_ => {}
}
}
if !multiple {
return first_match;
}
first_in_scope.or(first_imported).or(first_match)
}
fn module_matches_qualifier(module: &str, qualifier: &str) -> bool {
module == qualifier || module.ends_with(&format!("::{qualifier}"))
}
fn lookup_qualified_divert(
index: &SymbolIndex,
scope: &ImportScope,
path: &str,
) -> Option<DefinitionId> {
let (qualifier, name) = path.rsplit_once("::")?;
let ids = index.by_name.get(name)?;
ids.iter().find_map(|id| {
let info = index.symbols.get(id)?;
if info.kind != SymbolKind::Knot || info.visibility != Visibility::Public {
return None;
}
let module = info.module.as_deref()?;
if !module_matches_qualifier(module, qualifier) {
return None;
}
scope.qualified_modules.contains(module).then_some(*id)
})
}
fn resolve_variable(
index: &SymbolIndex,
scope: &ImportScope,
locals: &[LocalSymbol],
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
map: &mut ResolutionMap,
diagnostics: &mut Vec<Diagnostic>,
) {
let path = &uref.path;
match lookup_variable(index, scope, locals, uref) {
VarResult::Found(id) => {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
}
VarResult::Ambiguous => {
diagnostics.push(ambiguous_diag(file_id, uref.range, path));
}
VarResult::NotFound => {
if path == "none" {
return;
}
if is_builtin_function(path) {
return;
}
diagnostics.push(unresolved_diag(
index,
scope,
file_id,
uref.range,
path,
DiagnosticCode::E025,
&[],
));
}
}
}
enum VarResult {
Found(DefinitionId),
Ambiguous,
NotFound,
}
fn lookup_variable(
index: &SymbolIndex,
scope: &ImportScope,
locals: &[LocalSymbol],
uref: &brink_ir::UnresolvedRef,
) -> VarResult {
let path = &uref.path;
if let Some(id) = lookup_local_in_scope(locals, path, &uref.scope) {
return VarResult::Found(id);
}
if let Some(id) = lookup_by_name(
index,
scope,
path,
&[SymbolKind::Variable, SymbolKind::Constant],
) {
return VarResult::Found(id);
}
match lookup_list_item_bare(index, path) {
BareItemResult::Unique(id) => return VarResult::Found(id),
BareItemResult::Ambiguous => return VarResult::Ambiguous,
BareItemResult::NotFound => {}
}
if path.contains('.')
&& let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::ListItem])
{
return VarResult::Found(id);
}
if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List]) {
return VarResult::Found(id);
}
if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Knot, SymbolKind::Stitch]) {
return VarResult::Found(id);
}
if let Some(knot) = &uref.scope.knot
&& let Some(id) = lookup_by_name(
index,
scope,
&format!("{knot}.{path}"),
&[SymbolKind::Stitch],
)
{
return VarResult::Found(id);
}
if path.contains('.') {
if let Some(id) =
lookup_by_name(index, scope, path, &[SymbolKind::Stitch, SymbolKind::Label])
{
return VarResult::Found(id);
}
if let Some((knot, label)) = path.split_once('.')
&& !label.contains('.')
&& let Some(id) = lookup_label_in_knot(index, scope, knot, label)
{
return VarResult::Found(id);
}
}
if let Some(knot) = &uref.scope.knot
&& let Some(id) = lookup_label_in_knot(index, scope, knot, path)
{
return VarResult::Found(id);
}
if uref.scope.knot.is_none()
&& let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Label])
{
return VarResult::Found(id);
}
if let Some((head, _rest)) = path.split_once('.') {
if let Some(id) = lookup_local_in_scope(locals, head, &uref.scope) {
return VarResult::Found(id);
}
if let Some(id) = lookup_by_name(
index,
scope,
head,
&[SymbolKind::Variable, SymbolKind::Constant],
) {
return VarResult::Found(id);
}
}
VarResult::NotFound
}
fn resolve_function(
index: &SymbolIndex,
scope: &ImportScope,
locals: &[LocalSymbol],
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
map: &mut ResolutionMap,
diagnostics: &mut Vec<Diagnostic>,
) {
let path = &uref.path;
if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::External]) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
check_arity(index, file_id, uref, id, diagnostics);
return;
}
if let Some(id) = lookup_knot_bare(index, scope, path) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
check_arity(index, file_id, uref, id, diagnostics);
return;
}
let reserved_call_site =
uref.arg_count.is_some() && (is_builtin_function(path) || is_t1b_stdlib_name(path));
if !reserved_call_site && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List])
{
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
if let Some(id) = lookup_local_in_scope(locals, path, &uref.scope) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
if !reserved_call_site
&& let Some(id) = lookup_by_name(
index,
scope,
path,
&[SymbolKind::Variable, SymbolKind::Constant],
)
{
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
if uref.arg_count.is_none() {
match lookup_list_item_bare(index, path) {
BareItemResult::Unique(id) => {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
BareItemResult::Ambiguous => {
diagnostics.push(ambiguous_diag(file_id, uref.range, path));
return;
}
BareItemResult::NotFound => {}
}
}
if is_t1b_stdlib_name(path) || is_builtin_function(path) {
return;
}
if uref.arg_count.is_some()
&& let Some((head, _rest)) = path.split_once('.')
&& let Some(id) = lookup_local_in_scope(locals, head, &uref.scope).or_else(|| {
lookup_by_name(
index,
scope,
head,
&[SymbolKind::Variable, SymbolKind::Constant],
)
})
{
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
diagnostics.push(unresolved_diag(
index,
scope,
file_id,
uref.range,
path,
DiagnosticCode::E025,
&[SymbolKind::Knot],
));
}
fn check_arity(
index: &SymbolIndex,
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
target: DefinitionId,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(call_arg_count) = uref.arg_count else {
return;
};
let Some(info) = index.symbols.get(&target) else {
return;
};
let expected = info.params.len();
if call_arg_count != expected {
diagnostics.push(Diagnostic {
file: file_id,
range: uref.range,
message: format!(
"{}: `{}` expects {} argument(s), got {}",
DiagnosticCode::E031.title(),
uref.path,
expected,
call_arg_count,
),
code: DiagnosticCode::E031,
});
}
}
fn resolve_list_ref(
index: &SymbolIndex,
scope: &ImportScope,
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
map: &mut ResolutionMap,
diagnostics: &mut Vec<Diagnostic>,
) {
let path = &uref.path;
if path.contains('.')
&& let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::ListItem])
{
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
match lookup_list_item_bare(index, path) {
BareItemResult::Unique(id) => {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
BareItemResult::Ambiguous => {
diagnostics.push(ambiguous_diag(file_id, uref.range, path));
return;
}
BareItemResult::NotFound => {}
}
if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List]) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
diagnostics.push(unresolved_diag(
index,
scope,
file_id,
uref.range,
path,
DiagnosticCode::E025,
&[],
));
}
fn resolve_struct_ref(
index: &SymbolIndex,
scope: &ImportScope,
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
map: &mut ResolutionMap,
diagnostics: &mut Vec<Diagnostic>,
) {
let path = &uref.path;
if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Struct]) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
return;
}
diagnostics.push(unresolved_diag(
index,
scope,
file_id,
uref.range,
path,
DiagnosticCode::E068,
&[],
));
}
fn resolve_type_ref(
index: &SymbolIndex,
scope: &ImportScope,
file_id: FileId,
uref: &brink_ir::UnresolvedRef,
map: &mut ResolutionMap,
) {
if let Some(id) = lookup_by_name(index, scope, &uref.path, &[SymbolKind::Struct]) {
map.push(ResolvedRef {
file: file_id,
range: uref.range,
target: id,
});
}
}
fn lookup_local_in_scope(
locals: &[LocalSymbol],
bare_name: &str,
scope: &Scope,
) -> Option<DefinitionId> {
let mut best: Option<&LocalSymbol> = None;
for local in locals {
if local.name != bare_name {
continue;
}
if local.scope.knot != scope.knot {
continue;
}
if local.scope.stitch.is_some() && local.scope.stitch != scope.stitch {
continue;
}
match best {
Some(prev) if local.range.start() > prev.range.start() => {
best = Some(local);
}
None => {
best = Some(local);
}
_ => {}
}
}
best.map(|local| local_definition_id(&local.scope, &local.name, local.kind))
}
pub fn is_builtin_function(name: &str) -> bool {
brink_ir::lir::is_builtin_function(name)
}
pub fn is_t1b_stdlib_name(name: &str) -> bool {
brink_ir::lir::is_t1b_stdlib_name(name)
}
pub(crate) fn lookup_by_name(
index: &SymbolIndex,
scope: &ImportScope,
name: &str,
kinds: &[SymbolKind],
) -> Option<DefinitionId> {
if let Some(id) = lookup_by_name_direct(index, scope, name, kinds) {
return Some(id);
}
let (module, source_name) = scope.aliases.get(name)?;
let ids = index.by_name.get(source_name.as_str())?;
ids.iter().find_map(|id| {
let info = index.symbols.get(id)?;
(kinds.contains(&info.kind) && info.module.as_deref() == Some(module.as_str()))
.then_some(*id)
})
}
fn lookup_by_name_direct(
index: &SymbolIndex,
scope: &ImportScope,
name: &str,
kinds: &[SymbolKind],
) -> Option<DefinitionId> {
let ids = index.by_name.get(name)?;
let mut first_match: Option<DefinitionId> = None;
let mut first_in_scope: Option<DefinitionId> = None;
let mut first_imported: Option<DefinitionId> = None;
let mut multiple = false;
for id in ids {
let Some(info) = index.symbols.get(id) else {
continue;
};
if !kinds.contains(&info.kind) {
continue;
}
let candidacy = classify(scope, info);
if candidacy == Candidacy::Other
&& info.module.as_deref().is_some_and(is_reserved_root_module)
{
continue;
}
if first_match.is_none() {
first_match = Some(*id);
} else {
multiple = true;
}
match candidacy {
Candidacy::InScope if first_in_scope.is_none() => first_in_scope = Some(*id),
Candidacy::Imported if first_imported.is_none() => first_imported = Some(*id),
_ => {}
}
}
if !multiple {
return first_match;
}
first_in_scope.or(first_imported).or(first_match)
}
pub(crate) fn lookup_unique_by_name(
index: &SymbolIndex,
name: &str,
kinds: &[SymbolKind],
referrer_module: Option<&str>,
) -> Option<DefinitionId> {
let ids = index.by_name.get(name)?;
let mut sole = None;
for id in ids {
let Some(info) = index.symbols.get(id) else {
continue;
};
if !kinds.contains(&info.kind) {
continue;
}
if info.module.as_deref().is_some_and(is_reserved_root_module)
&& info.module.as_deref() != referrer_module
{
continue;
}
if sole.is_some() {
return None;
}
sole = Some(*id);
}
sole
}
pub(crate) enum BareItemResult {
Unique(DefinitionId),
Ambiguous,
NotFound,
}
pub(crate) fn lookup_list_item_bare(index: &SymbolIndex, bare_name: &str) -> BareItemResult {
let suffix = format!(".{bare_name}");
let mut found: Option<DefinitionId> = None;
for (name, ids) in &index.by_name {
if name.ends_with(&suffix) {
for id in ids {
if let Some(info) = index.symbols.get(id)
&& info.kind == SymbolKind::ListItem
{
if found.is_some() {
return BareItemResult::Ambiguous;
}
found = Some(*id);
}
}
}
}
match found {
Some(id) => BareItemResult::Unique(id),
None => BareItemResult::NotFound,
}
}
fn lookup_label_in_knot(
index: &SymbolIndex,
scope: &ImportScope,
knot: &str,
label: &str,
) -> Option<DefinitionId> {
let direct = format!("{knot}.{label}");
if let Some(id) = lookup_by_name(index, scope, &direct, &[SymbolKind::Label]) {
return Some(id);
}
let suffix = format!(".{label}");
let prefix = format!("{knot}.");
let mut best: Option<DefinitionId> = None;
for (name, ids) in &index.by_name {
if name.starts_with(&prefix) && name.ends_with(&suffix) && name.matches('.').count() == 2 {
for id in ids {
if let Some(info) = index.symbols.get(id)
&& info.kind == SymbolKind::Label
{
best = Some(match best {
Some(prev) if prev.to_raw() <= id.to_raw() => prev,
_ => *id,
});
}
}
}
}
best
}
fn ambiguous_diag(file: FileId, range: rowan::TextRange, path: &str) -> Diagnostic {
Diagnostic {
file,
range,
message: format!(
"{}: `{path}` — qualify with the list name (e.g., `ListName.{path}`)",
DiagnosticCode::E027.title(),
),
code: DiagnosticCode::E027,
}
}
fn is_std_shadowed_name(index: &SymbolIndex, path: &str) -> bool {
index.by_name.get(path).is_some_and(|ids| {
ids.iter().any(|id| {
index
.symbols
.get(id)
.is_some_and(|info| info.module.as_deref().is_some_and(is_reserved_root_module))
})
})
}
fn qualified_import_only_hint(
index: &SymbolIndex,
scope: &ImportScope,
path: &str,
kinds: &[SymbolKind],
) -> Option<String> {
let ids = index.by_name.get(path)?;
ids.iter().find_map(|id| {
let info = index.symbols.get(id)?;
if !kinds.contains(&info.kind) || !is_qualified_import_only(scope, info) {
return None;
}
info.module.clone()
})
}
fn unresolved_diag(
index: &SymbolIndex,
scope: &ImportScope,
file: FileId,
range: rowan::TextRange,
path: &str,
code: DiagnosticCode,
qualified_only_kinds: &[SymbolKind],
) -> Diagnostic {
let message = if is_std_shadowed_name(index, path) {
format!(
"{}: `{path}` — a declaration of this name exists under the `std::` peer root \
(either the mounted stdlib, or your own project file at a `std/…` path); bare \
names under `std::` are invisible outside it by rule, not by mistake — reference \
it with `use std::…` (docs/modules-spec.md §4)",
code.title(),
)
} else if let Some(module) =
qualified_import_only_hint(index, scope, path, qualified_only_kinds)
{
format!(
"{}: `{path}` — exported by `{module}`, which this file imports only as a module; \
a module import never brings bare names into scope — import it from `{module}` \
(see modules-spec §2)",
code.title(),
)
} else {
format!("{}: `{path}`", code.title())
};
Diagnostic {
file,
range,
message,
code,
}
}
#[cfg(test)]
#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
mod tests {
use brink_ir::{DeclaredSymbol, ImportItem, Scope, UnresolvedRef};
use rowan::TextRange;
use rowan::TextSize;
use super::*;
use crate::manifest::merge_manifests;
fn range(offset: u32, len: u32) -> TextRange {
TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
}
fn make_manifest(
knots: &[&str],
stitches: &[&str],
variables: &[&str],
lists: &[(&str, &[&str])],
externals: &[&str],
labels: &[&str],
unresolved: Vec<UnresolvedRef>,
) -> SymbolManifest {
let mut manifest = SymbolManifest::default();
let mut offset = 0u32;
for &name in knots {
let r = range(offset, name.len() as u32);
manifest.knots.push(DeclaredSymbol {
name: name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += name.len() as u32 + 1;
}
for &name in stitches {
let r = range(offset, name.len() as u32);
manifest.stitches.push(DeclaredSymbol {
name: name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += name.len() as u32 + 1;
}
for &name in variables {
let r = range(offset, name.len() as u32);
manifest.variables.push(DeclaredSymbol {
name: name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += name.len() as u32 + 1;
}
for &(list_name, items) in lists {
let r = range(offset, list_name.len() as u32);
manifest.lists.push(DeclaredSymbol {
name: list_name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += list_name.len() as u32 + 1;
for &item in items {
let qualified = format!("{list_name}.{item}");
let r = range(offset, item.len() as u32);
manifest.list_items.push(DeclaredSymbol {
name: qualified,
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += item.len() as u32 + 1;
}
}
for &name in externals {
let r = range(offset, name.len() as u32);
manifest.externals.push(DeclaredSymbol {
name: name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += name.len() as u32 + 1;
}
for &name in labels {
let r = range(offset, name.len() as u32);
manifest.labels.push(DeclaredSymbol {
name: name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
offset += name.len() as u32 + 1;
}
manifest.unresolved = unresolved;
manifest
}
fn uref(path: &str, kind: RefKind, knot: Option<&str>, stitch: Option<&str>) -> UnresolvedRef {
uref_with_args(path, kind, knot, stitch, None)
}
fn uref_with_args(
path: &str,
kind: RefKind,
knot: Option<&str>,
stitch: Option<&str>,
arg_count: Option<usize>,
) -> UnresolvedRef {
UnresolvedRef {
path: path.to_string(),
range: range(900, path.len() as u32),
kind,
scope: Scope {
knot: knot.map(String::from),
stitch: stitch.map(String::from),
},
arg_count,
module_qualified: false,
}
}
#[test]
fn single_knot_divert_resolves() {
let manifest = make_manifest(
&["start"],
&[],
&[],
&[],
&[],
&[],
vec![uref("start", RefKind::Divert, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, merge_diags) = merge_manifests(&files);
let (resolutions, resolve_diags) = resolve_refs(&index, &files);
assert!(merge_diags.is_empty());
assert!(resolve_diags.is_empty());
assert_eq!(resolutions.len(), 1);
assert_eq!(resolutions[0].file, FileId(0));
}
#[test]
fn qualified_knot_stitch_divert_resolves() {
let manifest = make_manifest(
&["kitchen"],
&["kitchen.look_around"],
&[],
&[],
&[],
&[],
vec![uref("kitchen.look_around", RefKind::Divert, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
}
#[test]
fn stitch_local_divert_prefers_local_stitch() {
let manifest = make_manifest(
&["bedroom", "kitchen"],
&["bedroom.look", "kitchen.look"],
&[],
&[],
&[],
&[],
vec![uref("look", RefKind::Divert, Some("bedroom"), None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
let info = index.symbols.get(&resolutions[0].target).unwrap();
assert_eq!(info.name, "bedroom.look");
}
#[test]
fn unresolved_divert_emits_diagnostic() {
let manifest = make_manifest(
&["start"],
&[],
&[],
&[],
&[],
&[],
vec![uref("nonexistent", RefKind::Divert, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(resolutions.is_empty());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E024);
}
#[test]
fn duplicate_knot_emits_warning() {
let mut m1 = make_manifest(&["start"], &[], &[], &[], &[], &[], vec![]);
let m2 = make_manifest(&["start"], &[], &[], &[], &[], &[], vec![]);
m1.knots[0].range = range(0, 5);
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 cross_file_duplicate_knot_local_does_not_leak_across_files() {
let mut a = SymbolManifest::default();
a.knots.push(brink_ir::DeclaredSymbol {
name: "dup".to_string(),
range: range(0, 3),
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
a.locals.push(LocalSymbol {
name: "t".to_string(),
range: range(10, 1),
scope: Scope {
knot: Some("dup".to_string()),
stitch: None,
},
kind: SymbolKind::Temp,
param_detail: None,
annotation: None,
});
let mut b = SymbolManifest::default();
b.knots.push(brink_ir::DeclaredSymbol {
name: "dup".to_string(),
range: range(100, 3),
params: Vec::new(),
detail: None,
visibility: None,
was: None,
}); b.unresolved
.push(uref("t", RefKind::Variable, Some("dup"), None));
let files = vec![(FileId(0), &a), (FileId(1), &b)];
let (index, merge_diags) = merge_manifests(&files);
assert_eq!(merge_diags.len(), 1, "duplicate knot should warn once");
assert_eq!(merge_diags[0].code, DiagnosticCode::E022);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(
resolutions.is_empty(),
"B's reference to an undeclared local must not resolve, got {resolutions:?}"
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E025);
}
#[test]
fn list_item_bare_name_resolves() {
let manifest = make_manifest(
&[],
&[],
&[],
&[("Colors", &["red", "green", "blue"])],
&[],
&[],
vec![uref("red", RefKind::Variable, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
let info = index.symbols.get(&resolutions[0].target).unwrap();
assert_eq!(info.name, "Colors.red");
}
#[test]
fn end_done_not_in_unresolved() {
let manifest = make_manifest(
&["start"],
&[],
&[],
&[],
&[],
&[],
vec![], );
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert!(resolutions.is_empty());
}
#[test]
fn label_in_knot_resolves() {
let manifest = make_manifest(
&["meeting"],
&[],
&[],
&[],
&[],
&["meeting.greet"],
vec![uref("greet", RefKind::Divert, Some("meeting"), None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
let info = index.symbols.get(&resolutions[0].target).unwrap();
assert_eq!(info.name, "meeting.greet");
}
#[test]
fn external_function_resolves() {
let manifest = make_manifest(
&[],
&[],
&[],
&[],
&["print_debug"],
&[],
vec![uref("print_debug", RefKind::Function, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
}
#[test]
fn global_variable_resolves() {
let manifest = make_manifest(
&[],
&[],
&["player_name"],
&[],
&[],
&[],
vec![uref("player_name", RefKind::Variable, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
}
#[test]
fn ambiguous_bare_list_item_emits_diagnostic() {
let manifest = make_manifest(
&[],
&[],
&[],
&[("Fruit", &["red"]), ("Color", &["red"])],
&[],
&[],
vec![uref("red", RefKind::Variable, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(resolutions.is_empty());
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E027);
}
#[test]
fn qualified_list_item_resolves_despite_ambiguity() {
let manifest = make_manifest(
&[],
&[],
&[],
&[("Fruit", &["red"]), ("Color", &["red"])],
&[],
&[],
vec![uref("Color.red", RefKind::Variable, None, None)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert!(diags.is_empty());
assert_eq!(resolutions.len(), 1);
let info = index.symbols.get(&resolutions[0].target).unwrap();
assert_eq!(info.name, "Color.red");
}
fn make_manifest_with_params(
knot_name: &str,
param_count: usize,
unresolved: Vec<UnresolvedRef>,
) -> SymbolManifest {
let mut manifest = SymbolManifest::default();
let r = range(0, knot_name.len() as u32);
let params: Vec<brink_ir::ParamInfo> = (0..param_count)
.map(|i| brink_ir::ParamInfo {
name: format!("p{i}"),
is_ref: false,
is_divert: false,
})
.collect();
manifest.knots.push(DeclaredSymbol {
name: knot_name.to_string(),
range: r,
params,
detail: Some("function".to_string()),
visibility: None,
was: None,
});
manifest.unresolved = unresolved;
manifest
}
#[test]
fn arity_match_no_warning() {
let manifest = make_manifest_with_params(
"greet",
1,
vec![uref_with_args(
"greet",
RefKind::Function,
None,
None,
Some(1),
)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert_eq!(resolutions.len(), 1);
assert!(
diags.is_empty(),
"expected no diagnostics for matching arity, got: {diags:?}"
);
}
#[test]
fn arity_mismatch_emits_e031() {
let manifest = make_manifest_with_params(
"greet",
1,
vec![uref_with_args(
"greet",
RefKind::Function,
None,
None,
Some(2),
)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert_eq!(
resolutions.len(),
1,
"should still resolve despite arity mismatch"
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E031);
assert!(diags[0].message.contains("expects 1"));
assert!(diags[0].message.contains("got 2"));
}
#[test]
fn arity_check_no_arg_count_no_warning() {
let manifest =
make_manifest_with_params("greet", 1, vec![uref("greet", RefKind::Divert, None, None)]);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (_resolutions, diags) = resolve_refs(&index, &files);
assert!(
diags.is_empty(),
"a ref with no arg_count should not trigger arity check: {diags:?}"
);
}
fn make_manifest_with_knot_and_variable(
knot_name: &str,
param_count: usize,
variable_name: &str,
unresolved: Vec<UnresolvedRef>,
) -> SymbolManifest {
let mut manifest = make_manifest_with_params(knot_name, param_count, Vec::new());
let r = range(9000, variable_name.len() as u32);
manifest.variables.push(DeclaredSymbol {
name: variable_name.to_string(),
range: r,
params: Vec::new(),
detail: None,
visibility: None,
was: None,
});
manifest.unresolved = unresolved;
manifest
}
#[test]
fn divert_arity_match_emits_no_e176() {
let manifest = make_manifest_with_params(
"greet",
1,
vec![uref_with_args(
"greet",
RefKind::Divert,
None,
None,
Some(1),
)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert_eq!(resolutions.len(), 1);
assert!(
diags.is_empty(),
"expected no diagnostics for matching divert arity, got: {diags:?}"
);
}
#[test]
fn divert_arity_mismatch_emits_e176() {
let manifest = make_manifest_with_params(
"greet",
1,
vec![uref_with_args(
"greet",
RefKind::Divert,
None,
None,
Some(2),
)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert_eq!(
resolutions.len(),
1,
"should still resolve despite arity mismatch"
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E176);
assert!(diags[0].message.contains("expects 1"));
assert!(diags[0].message.contains("got 2"));
}
#[test]
fn divert_through_variable_is_not_arity_checked() {
let manifest = make_manifest_with_knot_and_variable(
"greet",
1,
"holder",
vec![uref_with_args(
"holder",
RefKind::Divert,
None,
None,
Some(3),
)],
);
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert_eq!(resolutions.len(), 1, "the Variable must still resolve");
assert!(
diags.is_empty(),
"a divert through a Variable resolution must never be arity-checked: {diags:?}"
);
}
#[test]
fn arity_mismatch_external() {
let mut manifest = SymbolManifest::default();
let r = range(0, 5);
manifest.externals.push(DeclaredSymbol {
name: "print".to_string(),
range: r,
params: vec![brink_ir::ParamInfo {
name: "msg".into(),
is_ref: false,
is_divert: false,
}],
detail: None,
visibility: None,
was: None,
});
manifest.unresolved.push(uref_with_args(
"print",
RefKind::Function,
None,
None,
Some(3),
));
let files = vec![(FileId(0), &manifest)];
let (index, _) = merge_manifests(&files);
let (resolutions, diags) = resolve_refs(&index, &files);
assert_eq!(resolutions.len(), 1);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E031);
}
fn build_real(src: &str) -> (SymbolIndex, ResolutionMap) {
let parsed = brink_syntax::parse(src);
let (_hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let (index, _diag) = merge_manifests(&[(FileId(0), &manifest)]);
let (resolutions, _diag) =
resolve_file(&index, &ImportScope::default(), FileId(0), &manifest);
(index, resolutions)
}
fn resolved_kind_at(
src: &str,
index: &SymbolIndex,
resolutions: &ResolutionMap,
needle: &str,
) -> SymbolKind {
let start = src
.rfind(needle)
.expect("needle not found in fixture source");
#[expect(
clippy::cast_possible_truncation,
reason = "test fixture offsets fit in u32"
)]
let range = rowan::TextRange::new(
rowan::TextSize::from(start as u32),
rowan::TextSize::from((start + needle.len()) as u32),
);
let target = resolutions
.iter()
.find(|r| r.range == range)
.expect("no resolution spanning the needle's exact range")
.target;
index
.symbols
.get(&target)
.expect("resolved target missing from index")
.kind
}
#[test]
fn resolution_fallback_static_dotted_path_wins_over_a_colliding_variable_name() {
let src = "VAR knot = 0\n=== knot ===\n= x\nHello.\n-> DONE\n\
=== main ===\n~ y = knot.x\n-> DONE\n";
let (index, resolutions) = build_real(src);
assert_eq!(
resolved_kind_at(src, &index, &resolutions, "knot.x"),
SymbolKind::Stitch,
"the static `knot.x` stitch path must win over the colliding `knot` variable"
);
}
#[test]
fn resolution_fallback_resolves_to_head_variable_when_no_static_path_matches() {
let src = "VAR p = 0\n=== main ===\n~ y = p.x\n-> DONE\n";
let (index, resolutions) = build_real(src);
assert_eq!(
resolved_kind_at(src, &index, &resolutions, "p.x"),
SymbolKind::Variable
);
}
#[test]
fn resolution_fallback_resolves_to_head_param() {
let src = "=== main(p) ===\n~ y = p.x\n-> DONE\n";
let (index, resolutions) = build_real(src);
assert_eq!(
resolved_kind_at(src, &index, &resolutions, "p.x"),
SymbolKind::Param
);
}
#[test]
fn resolution_fallback_does_not_apply_to_a_single_segment_path() {
let src = "VAR p = 0\n=== main ===\n~ y = p\n-> DONE\n";
let (index, resolutions) = build_real(src);
assert_eq!(
resolved_kind_at(src, &index, &resolutions, "p"),
SymbolKind::Variable
);
}
#[test]
fn struct_literal_resolves_shape_name_to_the_declared_struct() {
let src = "STRUCT Point = #{x: float}\n=== main ===\n~ p = Point#{x: 1.0}\n-> DONE\n";
let (index, resolutions) = build_real(src);
assert_eq!(
resolved_kind_at(src, &index, &resolutions, "Point"),
SymbolKind::Struct
);
}
#[test]
fn struct_literal_unresolved_shape_name_is_e068() {
let src = "=== main ===\n~ p = Bogus#{x: 1}\n-> DONE\n";
let parsed = brink_syntax::parse(src);
let (_hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let (index, _diag) = merge_manifests(&[(FileId(0), &manifest)]);
let (resolutions, diags) =
resolve_file(&index, &ImportScope::default(), FileId(0), &manifest);
assert!(
resolutions.is_empty(),
"no resolution for an undeclared shape: {resolutions:?}"
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E068),
"{diags:?}"
);
}
fn two_module_ambush_index() -> (SymbolIndex, DefinitionId, DefinitionId) {
use brink_format::DefinitionTag;
let mut index = SymbolIndex::default();
let mk = |index: &mut SymbolIndex, module: &str, hash: u64| {
let id = DefinitionId::new(DefinitionTag::Address, hash);
index.symbols.insert(
id,
SymbolInfo {
kind: SymbolKind::Knot,
file: FileId(0),
range: TextRange::default(),
id,
name: "ambush".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some(module.to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("ambush".to_string())
.or_default()
.push(id);
id
};
let a = mk(&mut index, "quest_a", 0xA);
let b = mk(&mut index, "quest_b", 0xB);
(index, a, b)
}
#[test]
fn import_scope_binds_each_importer_to_its_own_module() {
let (index, a, b) = two_module_ambush_index();
let scope_a = ImportScope {
file_module: None,
qualified_modules: ["quest_a".to_string()].into_iter().collect(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &scope_a, "ambush", &[SymbolKind::Knot]),
Some(a),
"a file importing quest_a binds quest_a's ambush"
);
let scope_b = ImportScope {
file_module: None,
qualified_modules: ["quest_b".to_string()].into_iter().collect(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &scope_b, "ambush", &[SymbolKind::Knot]),
Some(b),
"a file importing quest_b binds quest_b's ambush — not the flat first-winner"
);
}
#[test]
fn unique_lookup_agrees_with_scoped_lookup() {
let (index, a, b) = two_module_ambush_index();
assert_eq!(
lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
None,
"two same-named candidates: only the scoped lookup can decide, so decline"
);
assert_ne!(a, b, "the fixture must really hold two distinct candidates");
let mut single = SymbolIndex::default();
let (&only_id, only_info) = index
.symbols
.iter()
.find(|(id, _)| **id == a)
.expect("fixture id present");
single.symbols.insert(only_id, only_info.clone());
single.by_name.insert("ambush".to_string(), vec![only_id]);
let scope_a = ImportScope {
file_module: None,
qualified_modules: ["quest_a".to_string()].into_iter().collect(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
let scope_none = ImportScope {
file_module: None,
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
let unique = lookup_unique_by_name(&single, "ambush", &[SymbolKind::Knot], None);
assert_eq!(unique, Some(a));
assert_eq!(
unique,
lookup_by_name(&single, &scope_a, "ambush", &[SymbolKind::Knot])
);
assert_eq!(
unique,
lookup_by_name(&single, &scope_none, "ambush", &[SymbolKind::Knot]),
"the sole-candidate answer must not depend on the scope at all"
);
assert_eq!(
lookup_unique_by_name(&single, "ambush", &[SymbolKind::External], None),
None,
"the kind filter still gates the match"
);
}
#[test]
fn same_module_candidate_wins_over_imported_one() {
let (index, a, b) = two_module_ambush_index();
let scope = ImportScope {
file_module: Some("quest_b".to_string()),
qualified_modules: ["quest_a".to_string()].into_iter().collect(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
Some(b),
"own-module definition beats an imported homonym"
);
let _ = a;
}
fn ambush_index_with_modules(modules: &[&str]) -> (SymbolIndex, Vec<DefinitionId>) {
use brink_format::DefinitionTag;
let mut index = SymbolIndex::default();
let mut ids = Vec::new();
for (i, module) in modules.iter().enumerate() {
let id = DefinitionId::new(DefinitionTag::Address, 0xA + i as u64);
index.symbols.insert(
id,
SymbolInfo {
kind: SymbolKind::Knot,
file: FileId(0),
range: TextRange::default(),
id,
name: "ambush".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some((*module).to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("ambush".to_string())
.or_default()
.push(id);
ids.push(id);
}
(index, ids)
}
#[test]
fn std_mounted_sole_candidate_is_invisible_with_no_import() {
let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
assert_eq!(
lookup_by_name(
&index,
&ImportScope::default(),
"ambush",
&[SymbolKind::Knot]
),
None,
"a std-mounted definition must not resolve by bare name with no `use std::…` \
import — reaching it requires an explicit import, which does not exist yet \
(#1582/#2167), so today it must resolve to nothing rather than silently reach std"
);
}
#[test]
fn project_own_module_wins_over_a_coexisting_std_mount_candidate() {
let (index, ids) =
ambush_index_with_modules(&["story::story", "std::conventions::screenplay"]);
let project_id = ids[0];
let scope = ImportScope {
file_module: Some("story::story".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
Some(project_id),
"a file inside `story::story` must resolve its OWN `ambush`, never the coexisting \
std mount's same-named one"
);
}
#[test]
fn project_referencing_a_third_module_still_skips_a_coexisting_std_candidate() {
let (index, ids) =
ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
let project_id = ids[1];
let scope = ImportScope {
file_module: Some("story::another_module".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
Some(project_id),
"with neither candidate `InScope`, the std `Other` candidate must still be \
skipped rather than winning the flat first-inserted tie-break"
);
}
#[test]
fn unique_lookup_excludes_std_mounted_sole_candidate() {
let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
assert_eq!(
lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
None,
"a std-mounted sole candidate must not resolve through the scope-free path when \
the caller has no referrer-module hint — lookup_by_name returns None for it under \
the default scope, so lookup_unique_by_name must agree rather than silently \
reaching into std with no import"
);
}
#[test]
fn unique_lookup_skips_std_candidate_and_returns_the_ordinary_one() {
let (index, ids) =
ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
let project_id = ids[1];
assert_eq!(
lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
Some(project_id),
"the std candidate must be excluded from the sole-match count entirely, leaving \
the one ordinary candidate as the unique match"
);
let other_scope = ImportScope {
file_module: Some("story::another_module".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_unique_by_name(
&index,
"ambush",
&[SymbolKind::Knot],
Some("story::another_module")
),
lookup_by_name(&index, &other_scope, "ambush", &[SymbolKind::Knot]),
"the scope-free answer must agree with the scoped one for a scope where neither \
candidate is InScope"
);
let project_scope = ImportScope {
file_module: Some("story::story".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], Some("story::story")),
lookup_by_name(&index, &project_scope, "ambush", &[SymbolKind::Knot]),
"the scope-free answer must also agree with the scoped one for a scope where the \
ordinary candidate (not the std one) is InScope"
);
}
#[test]
fn unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module() {
let (index, ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
let std_id = ids[0];
let std_scope = ImportScope {
file_module: Some("std::conventions::screenplay".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &std_scope, "ambush", &[SymbolKind::Knot]),
Some(std_id),
"a referrer whose own file_module IS the std module keeps resolving the std \
candidate via lookup_by_name_direct's InScope tier — std's own internal \
references are untouched by the #2197/#2216 gates"
);
assert_eq!(
lookup_unique_by_name(
&index,
"ambush",
&[SymbolKind::Knot],
Some("std::conventions::screenplay")
),
lookup_by_name(&index, &std_scope, "ambush", &[SymbolKind::Knot]),
"with the referrer's own module threaded through, lookup_unique_by_name now agrees \
with lookup_by_name for a referrer inside the std tree looking up a std sibling — \
the #2233 fix"
);
}
#[test]
fn resolve_type_ref_reproduces_in_scope_std_sibling_with_referrer_module() {
let mut index = SymbolIndex::default();
let cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC0E);
index.symbols.insert(
cue_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: FileId(9),
range: TextRange::default(),
id: cue_id,
name: "Cue".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::conventions::screenplay".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("Cue".to_string())
.or_default()
.push(cue_id);
let std_scope = ImportScope {
file_module: Some("std::conventions::screenplay".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
let referrer_file = FileId(1); let uref = UnresolvedRef {
path: "Cue".to_string(),
range: range(0, 3),
kind: RefKind::Type,
scope: Scope::default(),
arg_count: None,
module_qualified: false,
};
let mut map: ResolutionMap = Vec::new();
resolve_type_ref(&index, &std_scope, referrer_file, &uref, &mut map);
assert_eq!(
map,
vec![ResolvedRef {
file: referrer_file,
range: uref.range,
target: cue_id,
}],
"a referrer inside std referencing a sibling std file's struct with no import \
resolves via lookup_by_name_direct's InScope tier — the exact case \
ShapeTable::resolve's old lookup_global-based fallback could never reach \
(it excluded every std-declared candidate unconditionally, referrer or not)"
);
}
#[test]
fn resolve_type_ref_silently_misses_a_scalar_keyword_name() {
let index = SymbolIndex::default();
let uref = UnresolvedRef {
path: "int".to_string(),
range: range(0, 3),
kind: RefKind::Type,
scope: Scope::default(),
arg_count: None,
module_qualified: false,
};
let mut map: ResolutionMap = Vec::new();
resolve_type_ref(&index, &ImportScope::default(), FileId(0), &uref, &mut map);
assert!(
map.is_empty(),
"`int` never names a declared STRUCT — this must not resolve, and (unlike \
RefKind::Struct's E068) resolve_type_ref never diagnoses a miss either, since a \
miss here is not necessarily wrong"
);
}
#[test]
fn resolve_type_ref_excludes_a_std_only_struct_with_no_project_homonym_or_import() {
let mut index = SymbolIndex::default();
let cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC0F);
index.symbols.insert(
cue_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: FileId(9),
range: TextRange::default(),
id: cue_id,
name: "Cue".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::conventions::screenplay".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("Cue".to_string())
.or_default()
.push(cue_id);
let uref = UnresolvedRef {
path: "Cue".to_string(),
range: range(0, 3),
kind: RefKind::Type,
scope: Scope::default(),
arg_count: None,
module_qualified: false,
};
let mut map: ResolutionMap = Vec::new();
resolve_type_ref(&index, &ImportScope::default(), FileId(0), &uref, &mut map);
assert!(
map.is_empty(),
"a struct only a mounted std module declares must not resolve for a `~ temp c: Cue`- \
shaped annotation with no project-side homonym and no import — the sole-candidate \
std-exclusion property `resolve_type_ref`'s own doc claims, unproven by any test \
through this path before: {map:?}"
);
}
#[test]
fn resolve_type_ref_picks_the_referrers_own_project_struct_over_a_coexisting_std_homonym() {
let mut index = SymbolIndex::default();
let std_cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC10);
index.symbols.insert(
std_cue_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: FileId(9),
range: TextRange::default(),
id: std_cue_id,
name: "Cue".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::conventions::screenplay".to_string()),
visibility: Visibility::Public,
},
);
let project_cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC11);
index.symbols.insert(
project_cue_id,
SymbolInfo {
kind: SymbolKind::Struct,
file: FileId(1),
range: TextRange::default(),
id: project_cue_id,
name: "Cue".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("story::market".to_string()),
visibility: Visibility::Public,
},
);
for id in [std_cue_id, project_cue_id] {
index.by_name.entry("Cue".to_string()).or_default().push(id);
}
let scope = ImportScope {
file_module: Some("story::market".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
let uref = UnresolvedRef {
path: "Cue".to_string(),
range: range(0, 3),
kind: RefKind::Type,
scope: Scope::default(),
arg_count: None,
module_qualified: false,
};
let mut map: ResolutionMap = Vec::new();
resolve_type_ref(&index, &scope, FileId(1), &uref, &mut map);
assert_eq!(
map,
vec![ResolvedRef {
file: FileId(1),
range: uref.range,
target: project_cue_id,
}],
"a `story::market` file's own `~ temp c: Cue` must resolve to `story::market`'s own \
Cue, never the coexisting std mount's same-named one: {map:?}"
);
}
#[test]
fn unique_lookup_still_declines_when_a_visible_std_sibling_is_ambiguous() {
let (index, _ids) =
ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
assert_eq!(
lookup_unique_by_name(
&index,
"ambush",
&[SymbolKind::Knot],
Some("std::conventions::screenplay")
),
None,
"with the std candidate now visible (referrer inside its own module) alongside a \
coexisting ordinary candidate, the name is genuinely ambiguous to this scope-free \
function — it must decline rather than silently pick either one"
);
}
#[test]
fn unique_lookup_still_excludes_a_different_std_sibling_module() {
let (index, ids) =
ambush_index_with_modules(&["std::conventions::screenplay", "std::conventions::other"]);
let other_id = ids[1];
assert_eq!(
lookup_unique_by_name(
&index,
"ambush",
&[SymbolKind::Knot],
Some("std::conventions::other")
),
Some(other_id),
"the referrer's own std module's candidate still resolves (InScope, exact match)"
);
let screenplay_scope = ImportScope {
file_module: Some("std::conventions::other".to_string()),
qualified_modules: BTreeSet::new(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
assert_eq!(
lookup_by_name(&index, &screenplay_scope, "ambush", &[SymbolKind::Knot]),
Some(other_id),
"sanity: lookup_by_name agrees — the referrer's own module wins, not the sibling"
);
}
#[test]
fn is_std_shadowed_name_true_when_only_a_std_candidate_exists() {
let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
assert!(
is_std_shadowed_name(&index, "ambush"),
"a name whose sole declaration lives under the std peer root must be reported as \
std-shadowed"
);
}
#[test]
fn is_std_shadowed_name_false_for_an_ordinary_project_name() {
let (index, _ids) = ambush_index_with_modules(&["story::story"]);
assert!(
!is_std_shadowed_name(&index, "ambush"),
"a name declared only in an ordinary project module must not be reported as \
std-shadowed"
);
assert!(
!is_std_shadowed_name(&index, "no_such_name"),
"a name with no declaration at all must not be reported as std-shadowed either"
);
}
#[test]
fn unresolved_diag_hints_at_std_shadowing_when_a_std_candidate_exists() {
let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
let diag = unresolved_diag(
&index,
&ImportScope::default(),
FileId(0),
range(0, 6),
"ambush",
DiagnosticCode::E025,
&[],
);
assert_eq!(diag.code, DiagnosticCode::E025);
assert!(
diag.message.contains("std::") && diag.message.contains("peer root"),
"the diagnostic for a name that IS declared, but only under std, must say so \
rather than reading as an ordinary unresolved-name error: {}",
diag.message
);
}
#[test]
fn unresolved_diag_stays_plain_when_no_std_candidate_exists() {
let index = SymbolIndex::default();
let diag = unresolved_diag(
&index,
&ImportScope::default(),
FileId(0),
range(0, 6),
"nope",
DiagnosticCode::E025,
&[],
);
assert_eq!(
diag.message,
format!("{}: `nope`", DiagnosticCode::E025.title()),
"a genuinely unresolved name (no std candidate anywhere) must keep the plain \
message — the hint must not fire spuriously"
);
}
#[test]
fn resolve_variable_hints_std_shadowing_for_a_projects_own_std_path_file() {
let mut index = SymbolIndex::default();
let id = DefinitionId::new(brink_format::DefinitionTag::Address, 0xF00D);
index.symbols.insert(
id,
SymbolInfo {
kind: SymbolKind::Variable,
file: FileId(0),
range: TextRange::default(),
id,
name: "screenplay_intro".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("std::conventions::screenplay".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("screenplay_intro".to_string())
.or_default()
.push(id);
let scope = ImportScope::default();
let locals: Vec<LocalSymbol> = Vec::new();
let mut map: ResolutionMap = Vec::new();
let mut diagnostics = Vec::new();
let uref = uref("screenplay_intro", RefKind::Variable, None, None);
resolve_variable(
&index,
&scope,
&locals,
FileId(1),
&uref,
&mut map,
&mut diagnostics,
);
assert!(map.is_empty(), "a std-shadowed candidate must not resolve");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
assert!(
diagnostics[0].message.contains("std::"),
"the real resolution path's E025 must carry the std-shadowing hint too, not just \
the diagnostic-formatting helper in isolation: {}",
diagnostics[0].message
);
}
#[test]
fn default_scope_falls_back_to_flat_first_winner() {
let (index, a, _b) = two_module_ambush_index();
assert_eq!(
lookup_by_name(
&index,
&ImportScope::default(),
"ambush",
&[SymbolKind::Knot]
),
Some(a),
"no imports → flat first-winner, unchanged from pre-M-2d"
);
}
#[test]
fn bare_import_grants_candidacy_only_for_its_own_named_item() {
let (index, _a, b) = two_module_ambush_index();
let scope = ImportScope::new(
None,
&[
Import {
module: "quest_a".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "other".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
},
Import {
module: "quest_b".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "ambush".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
},
],
);
assert_eq!(
lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
Some(b),
"bare-importing `other` from quest_a must not license quest_a's `ambush` — \
only quest_b's `ambush` (actually bare-imported) is a candidate"
);
}
#[test]
fn qualified_import_still_grants_candidacy_for_any_export() {
let (index, a, _b) = two_module_ambush_index();
let scope = ImportScope::new(
None,
&[Import {
module: "quest_a".to_string(),
module_range: TextRange::default(),
items: Vec::new(),
bare: false,
range: TextRange::default(),
}],
);
assert_eq!(
lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
Some(a),
"a qualified `IMPORT quest_a` still licenses quest_a's `ambush`"
);
}
#[test]
fn aliased_bare_import_resolves_via_its_local_alias() {
let (index, a, _b) = two_module_ambush_index();
let scope = ImportScope::new(
None,
&[Import {
module: "quest_a".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "ambush".to_string(),
alias: Some("b".to_string()),
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
assert_eq!(
lookup_by_name(&index, &scope, "b", &[SymbolKind::Knot]),
Some(a),
"`ambush AS b` must make `b` resolve to quest_a's `ambush`"
);
}
#[test]
fn aliased_bare_import_also_still_resolves_via_its_original_name() {
let (index, a, _b) = two_module_ambush_index();
let scope = ImportScope::new(
None,
&[Import {
module: "quest_a".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "ambush".to_string(),
alias: Some("b".to_string()),
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
assert_eq!(
lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
Some(a),
"the source name `ambush` must still resolve alongside its alias `b`"
);
}
#[test]
fn alias_does_not_resolve_the_wrong_kind() {
let (index, _a, _b) = two_module_ambush_index();
let scope = ImportScope::new(
None,
&[Import {
module: "quest_a".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "ambush".to_string(),
alias: Some("b".to_string()),
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
assert_eq!(
lookup_by_name(&index, &scope, "b", &[SymbolKind::Variable]),
None,
"`b` aliases a Knot; it must not resolve when a Variable is requested"
);
}
#[test]
fn unrelated_scope_has_no_alias_and_does_not_resolve() {
let (index, _a, _b) = two_module_ambush_index();
assert_eq!(
lookup_by_name(&index, &ImportScope::default(), "b", &[SymbolKind::Knot]),
None,
"a file with no import scope must never resolve an alias it never declared"
);
}
#[test]
fn alias_colliding_with_an_in_scope_direct_name_resolves_to_the_direct_name() {
use brink_format::DefinitionTag;
let mut index = SymbolIndex::default();
let local_start = DefinitionId::new(DefinitionTag::Address, 0x51A47);
index.symbols.insert(
local_start,
SymbolInfo {
kind: SymbolKind::Knot,
file: FileId(0),
range: TextRange::default(),
id: local_start,
name: "start".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: None,
visibility: Visibility::Public,
},
);
index
.by_name
.entry("start".to_string())
.or_default()
.push(local_start);
let (ambush_index, aliased_target, _b) = two_module_ambush_index();
for (name, ids) in ambush_index.by_name {
index.by_name.entry(name).or_default().extend(ids);
}
for (id, info) in ambush_index.symbols {
index.symbols.insert(id, info);
}
let scope = ImportScope::new(
None,
&[Import {
module: "quest_a".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "ambush".to_string(),
alias: Some("start".to_string()),
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
assert_eq!(
lookup_by_name(&index, &scope, "start", &[SymbolKind::Knot]),
Some(local_start),
"a direct in-scope `start` must win over the colliding alias — \
`ambush AS start` never reaches quest_a's ambush ({aliased_target:?}) \
under that name"
);
}
fn haggle_index(module: &str) -> (SymbolIndex, DefinitionId) {
use brink_format::DefinitionTag;
let mut index = SymbolIndex::default();
let id = DefinitionId::new(DefinitionTag::Address, 0x748);
index.symbols.insert(
id,
SymbolInfo {
kind: SymbolKind::Knot,
file: FileId(1),
range: TextRange::default(),
id,
name: "haggle".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some(module.to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("haggle".to_string())
.or_default()
.push(id);
(index, id)
}
fn divert_uref(path: &str, module_qualified: bool) -> UnresolvedRef {
UnresolvedRef {
path: path.to_string(),
range: range(0, path.len() as u32),
kind: RefKind::Divert,
scope: Scope::default(),
arg_count: None,
module_qualified,
}
}
#[test]
fn qualified_divert_resolves_via_module_qualified_import() {
let (index, haggle_id) = haggle_index("story::market::barter");
let scope = ImportScope::new(
None,
&[Import {
module: "story::market".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "barter".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
let uref = divert_uref("barter::haggle", true);
assert_eq!(
lookup_divert(&index, &scope, &[], &uref),
Some(haggle_id),
"`use story::market::barter;` must license the module-qualified \
`-> barter::haggle` divert"
);
}
#[test]
fn qualified_divert_rejected_with_no_import_and_message_uses_double_colon() {
let (index, _haggle_id) = haggle_index("story::market::barter");
let scope = ImportScope::default();
let uref = divert_uref("barter::haggle", true);
assert_eq!(
lookup_divert(&index, &scope, &[], &uref),
None,
"no import at all must not license the qualified divert"
);
let mut diagnostics = Vec::new();
let mut map = ResolutionMap::new();
resolve_divert(
&index,
&scope,
&[],
FileId(0),
&uref,
&mut map,
&mut diagnostics,
);
assert!(map.is_empty());
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagnosticCode::E024);
assert!(
diagnostics[0].message.contains("barter::haggle"),
"the E024 message must spell the qualified path with `::` (the \
native separator actually written), not `.`: {}",
diagnostics[0].message
);
}
#[test]
fn bare_divert_rejected_after_qualified_module_import_only() {
let (index, _haggle_id) = haggle_index("story::market::barter");
let scope = ImportScope::new(
None,
&[Import {
module: "story::market".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "barter".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
let uref = divert_uref("haggle", false);
assert_eq!(
lookup_divert(&index, &scope, &[], &uref),
None,
"a qualified-module-only import must not license the bare \
`-> haggle` spelling"
);
}
#[test]
fn bare_divert_resolves_via_symbol_level_import() {
let (index, haggle_id) = haggle_index("story::market::barter");
let scope = ImportScope::new(
None,
&[Import {
module: "story::market::barter".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "haggle".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
);
let uref = divert_uref("haggle", false);
assert_eq!(
lookup_divert(&index, &scope, &[], &uref),
Some(haggle_id),
"`use story::market::barter::haggle;` must license the bare \
`-> haggle` divert"
);
}
#[test]
fn bare_divert_still_resolves_with_no_import_deferring_to_the_e025_e087_gate() {
let (index, haggle_id) = haggle_index("story::market::barter");
let uref = divert_uref("haggle", false);
assert_eq!(
lookup_divert(&index, &ImportScope::default(), &[], &uref),
Some(haggle_id),
"a totally unimported cross-module Knot must still resolve here — \
`modules::check`'s E025/E087 gate is what rejects it, with a far \
more precise diagnostic than a bare E024 would give"
);
}
fn function_uref(path: &str, arg_count: usize) -> UnresolvedRef {
UnresolvedRef {
path: path.to_string(),
range: range(0, path.len() as u32),
kind: RefKind::Function,
scope: Scope::default(),
arg_count: Some(arg_count),
module_qualified: false,
}
}
fn qualified_module_only_scope() -> ImportScope {
ImportScope::new(
None,
&[Import {
module: "story::market".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "barter".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
)
}
fn symbol_level_import_scope() -> ImportScope {
ImportScope::new(
None,
&[Import {
module: "story::market::barter".to_string(),
module_range: TextRange::default(),
items: vec![ImportItem {
name: "haggle".to_string(),
alias: None,
range: TextRange::default(),
}],
bare: true,
range: TextRange::default(),
}],
)
}
#[test]
fn bare_call_rejected_after_qualified_module_import_only() {
let (index, _haggle_id) = haggle_index("story::market::barter");
let scope = qualified_module_only_scope();
let uref = function_uref("haggle", 0);
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
resolve_function(
&index,
&scope,
&[],
FileId(0),
&uref,
&mut map,
&mut diagnostics,
);
assert!(
map.is_empty(),
"a qualified-module-only import must not license the bare call \
`haggle()` — issue #2298's live gap, the call-site twin of \
#2287 bug (b): {map:?}"
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
}
#[test]
fn bare_call_resolves_via_symbol_level_import() {
let (index, haggle_id) = haggle_index("story::market::barter");
let scope = symbol_level_import_scope();
let uref = function_uref("haggle", 0);
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
resolve_function(
&index,
&scope,
&[],
FileId(0),
&uref,
&mut map,
&mut diagnostics,
);
assert!(
diagnostics.is_empty(),
"unexpected diagnostics: {diagnostics:?}"
);
assert_eq!(
map.iter().map(|r| r.target).collect::<Vec<_>>(),
vec![haggle_id],
"`use story::market::barter::haggle;` must license the bare call \
`haggle()`"
);
}
#[test]
fn bare_call_still_resolves_with_no_import_deferring_to_the_e025_e087_gate() {
let (index, haggle_id) = haggle_index("story::market::barter");
let uref = function_uref("haggle", 0);
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
resolve_function(
&index,
&ImportScope::default(),
&[],
FileId(0),
&uref,
&mut map,
&mut diagnostics,
);
assert_eq!(
map.iter().map(|r| r.target).collect::<Vec<_>>(),
vec![haggle_id],
"a totally unimported cross-module Knot must still resolve at a \
call site, exactly as it does at a divert site"
);
}
#[test]
fn unresolved_diag_hints_at_qualified_import_only_candidate_for_divert() {
let (index, _haggle_id) = haggle_index("story::market::barter");
let scope = qualified_module_only_scope();
let uref = divert_uref("haggle", false);
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
resolve_divert(
&index,
&scope,
&[],
FileId(0),
&uref,
&mut map,
&mut diagnostics,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagnosticCode::E024);
assert!(
diagnostics[0]
.message
.contains("import it from `story::market::barter`"),
"the module-imported-but-bare row must name the qualified-\
import-only candidate it skipped: {}",
diagnostics[0].message
);
}
#[test]
fn unresolved_diag_hints_at_qualified_import_only_candidate_for_call() {
let (index, _haggle_id) = haggle_index("story::market::barter");
let scope = qualified_module_only_scope();
let uref = function_uref("haggle", 0);
let mut map = ResolutionMap::new();
let mut diagnostics = Vec::new();
resolve_function(
&index,
&scope,
&[],
FileId(0),
&uref,
&mut map,
&mut diagnostics,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
assert!(
diagnostics[0]
.message
.contains("import it from `story::market::barter`"),
"the module-imported-but-bare call row must name the qualified-\
import-only candidate it skipped too: {}",
diagnostics[0].message
);
}
fn constant_index(name: &str) -> (SymbolIndex, DefinitionId) {
use brink_format::DefinitionTag;
let mut index = SymbolIndex::default();
let id = DefinitionId::new(DefinitionTag::Address, 0x749);
index.symbols.insert(
id,
SymbolInfo {
kind: SymbolKind::Constant,
file: FileId(0),
range: TextRange::default(),
id,
name: name.to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: None,
visibility: Visibility::Public,
},
);
index.by_name.entry(name.to_string()).or_default().push(id);
(index, id)
}
#[test]
fn divert_target_resolves_a_constant_symbol() {
let (index, const_id) = constant_index("target");
let uref = divert_uref("target", false);
assert_eq!(
lookup_divert(&index, &ImportScope::default(), &[], &uref),
Some(const_id),
"a top-level Constant-kind divert target must resolve via step \
6, matching resolve_function's own [Variable, Constant] \
call-site lookup (issue #2083's thread)"
);
}
#[test]
fn global_constant_divert_target_shadows_a_same_named_local() {
let (index, const_id) = constant_index("target");
let locals = vec![LocalSymbol {
name: "target".to_string(),
range: range(10, 6),
scope: Scope::default(),
kind: SymbolKind::Param,
param_detail: None,
annotation: None,
}];
let uref = divert_uref("target", false);
assert_eq!(
lookup_divert(&index, &ImportScope::default(), &locals, &uref),
Some(const_id),
"a global Constant divert target must shadow a same-named local \
at a divert site, matching the Variable species' established \
step-6-before-step-7 order"
);
}
#[test]
fn lookup_bare_excluding_qualified_only_respects_the_exclusion_for_non_knot_kinds() {
use brink_format::DefinitionTag;
let mut index = SymbolIndex::default();
let id = DefinitionId::new(DefinitionTag::Address, 0x750);
index.symbols.insert(
id,
SymbolInfo {
kind: SymbolKind::Stitch,
file: FileId(1),
range: TextRange::default(),
id,
name: "haggle".to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some("story::market::barter".to_string()),
visibility: Visibility::Public,
},
);
index
.by_name
.entry("haggle".to_string())
.or_default()
.push(id);
let scope = qualified_module_only_scope();
assert_eq!(
lookup_bare_excluding_qualified_only(&index, &scope, "haggle", &[SymbolKind::Stitch]),
None,
"a qualified-module-only import must not license a bare Stitch \
lookup any more than it licenses a bare Knot lookup"
);
assert_eq!(
lookup_bare_excluding_qualified_only(
&index,
&ImportScope::default(),
"haggle",
&[SymbolKind::Stitch]
),
Some(id)
);
}
}