use std::collections::{BTreeMap, BTreeSet};
use brink_ir::{
Diagnostic, DiagnosticCode, FileId, HirFile, ResolutionMap, SymbolIndex, SymbolInfo,
SymbolKind, Visibility,
};
fn is_importable(kind: SymbolKind) -> bool {
matches!(
kind,
SymbolKind::Knot
| SymbolKind::Variable
| SymbolKind::Constant
| SymbolKind::List
| SymbolKind::Struct
)
}
fn file_modules_and_exports(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
) -> (
BTreeMap<FileId, Option<String>>,
BTreeMap<String, BTreeSet<String>>,
) {
let mut file_module: BTreeMap<FileId, Option<String>> = BTreeMap::new();
let mut declared_exports: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for info in index.symbols.values() {
if matches!(info.kind, SymbolKind::Param | SymbolKind::Temp) {
continue;
}
file_module.entry(info.file).or_insert(info.module.clone());
if let Some(module) = &info.module
&& info.visibility == Visibility::Public
&& is_importable(info.kind)
{
declared_exports
.entry(module.clone())
.or_default()
.insert(info.name.clone());
}
}
for &(file_id, hir) in files {
file_module
.entry(file_id)
.or_insert_with(|| hir.module.as_ref().map(|decl| decl.name.clone()));
}
(file_module, declared_exports)
}
fn known_module_names(file_module: &BTreeMap<FileId, Option<String>>) -> BTreeSet<String> {
let mut known = BTreeSet::new();
for module in file_module.values().flatten() {
known.insert(module.clone());
let mut segments: Vec<&str> = module.split("::").collect();
while segments.len() > 1 {
segments.pop();
known.insert(segments.join("::"));
}
}
known
}
fn referrer_in_target_module(
file_module: &BTreeMap<FileId, Option<String>>,
target: &SymbolInfo,
ref_file: FileId,
) -> bool {
match &target.module {
Some(tmod) => file_module.get(&ref_file).and_then(Option::as_ref) == Some(tmod),
None => ref_file == target.file,
}
}
type ImportCoverage<'a> = (
BTreeMap<FileId, BTreeSet<String>>,
BTreeMap<FileId, BTreeSet<(&'a str, &'a str)>>,
);
fn import_coverage<'a>(files: &'a [(FileId, &'a HirFile)]) -> ImportCoverage<'a> {
let mut qualified: BTreeMap<FileId, BTreeSet<String>> = BTreeMap::new();
let mut bare: BTreeMap<FileId, BTreeSet<(&str, &str)>> = BTreeMap::new();
for &(file_id, hir) in files {
let (file_qualified, file_bare) = crate::resolve::import_coverage_for_file(&hir.imports);
if !file_qualified.is_empty() {
qualified.insert(file_id, file_qualified);
}
if !file_bare.is_empty() {
bare.insert(file_id, file_bare);
}
}
(qualified, bare)
}
fn import_covers(
qualified: &BTreeMap<FileId, BTreeSet<String>>,
bare: &BTreeMap<FileId, BTreeSet<(&str, &str)>>,
ref_file: FileId,
module: &str,
name: &str,
) -> bool {
if qualified
.get(&ref_file)
.is_some_and(|mods| mods.contains(module))
{
return true;
}
bare.get(&ref_file)
.is_some_and(|pairs| pairs.contains(&(module, name)))
}
fn symbol_visible_bare_in_file(
index: &SymbolIndex,
file_module: &BTreeMap<FileId, Option<String>>,
file_id: FileId,
name: &str,
) -> bool {
let Some(ids) = index.by_name.get(name) else {
return false;
};
ids.iter().any(|id| {
index.symbols.get(id).is_some_and(|info| {
!matches!(info.kind, SymbolKind::Param | SymbolKind::Temp)
&& referrer_in_target_module(file_module, info, file_id)
})
})
}
fn conventions_injected_call_ranges(
files: &[(FileId, &HirFile)],
) -> BTreeMap<FileId, BTreeSet<(u32, u32)>> {
let mut ranges: BTreeMap<FileId, BTreeSet<(u32, u32)>> = BTreeMap::new();
for &(file_id, hir) in files {
for m in &hir.element_matches {
if m.injected {
ranges
.entry(file_id)
.or_default()
.insert((m.line.start().into(), m.line.end().into()));
}
}
}
ranges
}
fn check_cross_module_refs(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
file_module: &BTreeMap<FileId, Option<String>>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (qualified_imports, bare_imports) = import_coverage(files);
let injected_calls = conventions_injected_call_ranges(files);
for r in resolutions {
let Some(target) = index.symbols.get(&r.target) else {
continue;
};
if matches!(target.kind, SymbolKind::Param | SymbolKind::Temp) {
continue;
}
if referrer_in_target_module(file_module, target, r.file) {
continue;
}
if injected_calls
.get(&r.file)
.is_some_and(|s| s.contains(&(r.range.start().into(), r.range.end().into())))
{
continue;
}
match target.visibility {
Visibility::Private => {
diagnostics.push(Diagnostic {
file: r.file,
range: r.range,
message: format!("{}: `{}`", DiagnosticCode::E087.title(), target.name),
code: DiagnosticCode::E087,
});
}
Visibility::Public => {
if let Some(tmod) = &target.module
&& !import_covers(
&qualified_imports,
&bare_imports,
r.file,
tmod,
&target.name,
)
{
diagnostics.push(Diagnostic {
file: r.file,
range: r.range,
message: format!(
"unresolved cross-module reference `{name}` — import it from `{module}` (see modules-spec §2)",
name = target.name,
module = tmod,
),
code: DiagnosticCode::E025,
});
}
}
}
}
}
fn check_qualified_ambiguity(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
file_module: &BTreeMap<FileId, Option<String>>,
diagnostics: &mut Vec<Diagnostic>,
) {
for &(file_id, hir) in files {
for import in &hir.imports {
if import.bare {
continue;
}
if symbol_visible_bare_in_file(index, file_module, file_id, &import.module) {
diagnostics.push(Diagnostic {
file: file_id,
range: import.module_range,
message: format!("{}: `{}`", DiagnosticCode::E091.title(), import.module),
code: DiagnosticCode::E091,
});
}
}
}
}
#[must_use]
pub fn check(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let (file_module, declared_exports) = file_modules_and_exports(files, index);
let known_modules = known_module_names(&file_module);
check_cross_module_refs(files, index, resolutions, &file_module, &mut diagnostics);
check_qualified_ambiguity(files, index, &file_module, &mut diagnostics);
for &(file_id, hir) in files {
if hir.imports.is_empty() {
continue;
}
let own_module = file_module.get(&file_id).and_then(Option::clone);
let mut seen_locals: BTreeSet<String> = BTreeSet::new();
let mut seen_modules: BTreeSet<String> = BTreeSet::new();
for import in &hir.imports {
if import.bare {
for item in &import.items {
if !seen_locals.insert(item.local_name().to_string()) {
diagnostics.push(Diagnostic {
file: file_id,
range: item.range,
message: format!(
"{}: `{}`",
DiagnosticCode::E089.title(),
item.local_name()
),
code: DiagnosticCode::E089,
});
}
let is_item = declared_exports
.get(&import.module)
.is_some_and(|exports| exports.contains(&item.name));
let full_path = format!("{}::{}", import.module, item.name);
let is_module = known_modules.contains(&full_path);
if !is_module && own_module.as_deref() == Some(import.module.as_str()) {
diagnostics.push(Diagnostic {
file: file_id,
range: import.module_range,
message: format!(
"{}: `{}`",
DiagnosticCode::E090.title(),
import.module
),
code: DiagnosticCode::E090,
});
}
if is_module && own_module.as_deref() == Some(full_path.as_str()) {
diagnostics.push(Diagnostic {
file: file_id,
range: item.range,
message: format!("{}: `{}`", DiagnosticCode::E090.title(), full_path),
code: DiagnosticCode::E090,
});
}
if is_module && item.alias.is_some() {
diagnostics.push(Diagnostic {
file: file_id,
range: item.range,
message: format!(
"{}: cannot alias imported module `{}`",
DiagnosticCode::E129.title(),
full_path
),
code: DiagnosticCode::E129,
});
}
if !is_item && !is_module && known_modules.contains(&import.module) {
diagnostics.push(Diagnostic {
file: file_id,
range: item.range,
message: format!(
"{}: `{}` from `{}`",
DiagnosticCode::E088.title(),
item.name,
import.module
),
code: DiagnosticCode::E088,
});
}
}
} else {
if own_module.as_deref() == Some(import.module.as_str()) {
diagnostics.push(Diagnostic {
file: file_id,
range: import.module_range,
message: format!("{}: `{}`", DiagnosticCode::E090.title(), import.module),
code: DiagnosticCode::E090,
});
}
if !seen_modules.insert(import.module.clone()) {
diagnostics.push(Diagnostic {
file: file_id,
range: import.module_range,
message: format!("{}: `{}`", DiagnosticCode::E089.title(), import.module),
code: DiagnosticCode::E089,
});
}
}
}
}
diagnostics
}
#[cfg(test)]
mod tests {
use brink_format::{DefinitionId, DefinitionTag};
use brink_ir::{
Block, DiagnosticCode, FileId, HirFile, Import, ImportItem, ModuleDecl, ResolvedRef, Scope,
SymbolIndex, SymbolInfo, SymbolKind, Visibility,
};
use rowan::{TextRange, TextSize};
use super::check;
fn range(offset: u32, len: u32) -> TextRange {
TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
}
fn hir_with_module(name: &str) -> HirFile {
hir_with_module_and_imports(name, Vec::new())
}
fn hir_with_module_and_imports(name: &str, imports: Vec<Import>) -> HirFile {
HirFile {
root_content: Block::default(),
knots: Vec::new(),
variables: Vec::new(),
constants: Vec::new(),
lists: Vec::new(),
structs: Vec::new(),
externals: Vec::new(),
includes: Vec::new(),
module: Some(ModuleDecl {
name: name.to_string(),
range: range(0, 1),
was: None,
}),
imports,
visibility: Vec::new(),
was_directives: Vec::new(),
allow_scopes: Vec::new(),
element_matches: Vec::new(),
cue_names: Vec::new(),
native: false,
claim_handlers: Vec::new(),
dispatch_handlers: Vec::new(),
}
}
fn bare_import(module: &str, item: &str) -> Import {
Import {
module: module.to_string(),
module_range: range(0, 1),
items: vec![ImportItem {
name: item.to_string(),
alias: None,
range: range(1, 1),
}],
bare: true,
range: range(0, 2),
}
}
fn bare_import_with_alias(module: &str, item: &str, alias: &str) -> Import {
Import {
module: module.to_string(),
module_range: range(0, 1),
items: vec![ImportItem {
name: item.to_string(),
alias: Some(alias.to_string()),
range: range(1, 1),
}],
bare: true,
range: range(0, 2),
}
}
fn symbol(
id: DefinitionId,
kind: SymbolKind,
name: &str,
module: Option<&str>,
scope: Option<Scope>,
) -> SymbolInfo {
SymbolInfo {
kind,
file: FileId(0),
range: range(0, 1),
id,
name: name.to_string(),
params: Vec::new(),
detail: None,
scope,
param_detail: None,
module: module.map(str::to_string),
visibility: Visibility::Private,
}
}
#[test]
fn single_file_declared_module_self_reference_never_e087() {
for _ in 0..64 {
let hir = hir_with_module("quest");
let files = [(FileId(0), &hir)];
let knot_id = DefinitionId::new(DefinitionTag::Address, 1);
let sibling_id = DefinitionId::new(DefinitionTag::Address, 2);
let temp_id = DefinitionId::new(DefinitionTag::LocalVar, 3);
let mut index = SymbolIndex::default();
index.symbols.insert(
knot_id,
symbol(knot_id, SymbolKind::Knot, "caller", Some("quest"), None),
);
index.symbols.insert(
sibling_id,
symbol(sibling_id, SymbolKind::Knot, "sibling", Some("quest"), None),
);
index.symbols.insert(
temp_id,
symbol(
temp_id,
SymbolKind::Temp,
"g",
None,
Some(Scope {
knot: Some("caller".to_string()),
stitch: None,
}),
),
);
for (name, id) in [("caller", knot_id), ("sibling", sibling_id), ("g", temp_id)] {
index.by_name.entry(name.to_string()).or_default().push(id);
}
let resolutions = vec![ResolvedRef {
file: FileId(0),
range: range(10, 7),
target: sibling_id,
}];
let diagnostics = check(&files, &index, &resolutions);
assert!(
diagnostics.is_empty(),
"same-module self-reference must produce no diagnostics, got {diagnostics:?}"
);
}
}
#[test]
fn e025_message_never_hardcodes_a_concrete_import_statement() {
let quest = hir_with_module("quest");
let town = hir_with_module("town");
let files = [(FileId(0), &quest), (FileId(1), &town)];
let ambush_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
ambush_id,
SymbolInfo {
visibility: Visibility::Public,
..symbol(ambush_id, SymbolKind::Knot, "ambush", Some("quest"), None)
},
);
index
.by_name
.entry("ambush".to_string())
.or_default()
.push(ambush_id);
let resolutions = vec![ResolvedRef {
file: FileId(1),
range: range(10, 6),
target: ambush_id,
}];
let diagnostics = check(&files, &index, &resolutions);
let e025: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E025)
.collect();
assert_eq!(e025.len(), 1, "expected exactly one E025: {diagnostics:?}");
assert!(
!e025[0].message.contains("IMPORT"),
"E025 message must not hardcode ink's IMPORT syntax: {:?}",
e025[0].message
);
}
#[test]
fn type_annotation_reference_to_unimported_public_struct_in_another_module_is_e025() {
let quest = hir_with_module("quest");
let town = hir_with_module("town");
let files = [(FileId(0), &quest), (FileId(1), &town)];
let cue_id = DefinitionId::new(DefinitionTag::StructDef, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
cue_id,
SymbolInfo {
visibility: Visibility::Public,
..symbol(cue_id, SymbolKind::Struct, "Cue", Some("quest"), None)
},
);
index
.by_name
.entry("Cue".to_string())
.or_default()
.push(cue_id);
let resolutions = vec![ResolvedRef {
file: FileId(1),
range: range(10, 3),
target: cue_id,
}];
let diagnostics = check(&files, &index, &resolutions);
assert!(
diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E025 && d.file == FileId(1)),
"unimported cross-module struct type annotation must raise E025: {diagnostics:?}"
);
}
#[test]
fn type_annotation_reference_to_imported_public_struct_in_another_module_is_silent() {
let quest = hir_with_module("quest");
let town = hir_with_module_and_imports("town", vec![bare_import("quest", "Cue")]);
let files = [(FileId(0), &quest), (FileId(1), &town)];
let cue_id = DefinitionId::new(DefinitionTag::StructDef, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
cue_id,
SymbolInfo {
visibility: Visibility::Public,
..symbol(cue_id, SymbolKind::Struct, "Cue", Some("quest"), None)
},
);
index
.by_name
.entry("Cue".to_string())
.or_default()
.push(cue_id);
let resolutions = vec![ResolvedRef {
file: FileId(1),
range: range(10, 3),
target: cue_id,
}];
let diagnostics = check(&files, &index, &resolutions);
assert!(
diagnostics.is_empty(),
"an imported cross-module struct type annotation must raise nothing: {diagnostics:?}"
);
}
#[test]
fn type_annotation_reference_to_private_struct_in_another_module_is_e087() {
let quest = hir_with_module("quest");
let town = hir_with_module("town");
let files = [(FileId(0), &quest), (FileId(1), &town)];
let cue_id = DefinitionId::new(DefinitionTag::StructDef, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
cue_id,
SymbolInfo {
visibility: Visibility::Private,
..symbol(cue_id, SymbolKind::Struct, "Cue", Some("quest"), None)
},
);
index
.by_name
.entry("Cue".to_string())
.or_default()
.push(cue_id);
let resolutions = vec![ResolvedRef {
file: FileId(1),
range: range(10, 3),
target: cue_id,
}];
let diagnostics = check(&files, &index, &resolutions);
assert!(
diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E087 && d.file == FileId(1)),
"a private struct referenced cross-module in a type annotation must raise E087: \
{diagnostics:?}"
);
}
#[test]
fn bare_import_from_a_declared_module_with_no_public_exports_is_e088() {
let vault = hir_with_module("vault");
let main = hir_with_module_and_imports("main", vec![bare_import("vault", "secret")]);
let files = [(FileId(0), &vault), (FileId(1), &main)];
let secret_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
secret_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Private,
..symbol(secret_id, SymbolKind::Knot, "secret", Some("vault"), None)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e088: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E088)
.collect();
assert_eq!(
e088.len(),
1,
"a bare import from a declared module that exports nothing publicly must diagnose \
E088 (the known_modules-superset widening), even though the named item genuinely \
exists (just private): {diagnostics:?}"
);
}
#[test]
fn dual_reading_trailing_segment_resolving_to_a_submodule_is_not_e088() {
let barter = hir_with_module("story::market::barter");
let main = hir_with_module_and_imports(
"story::main",
vec![bare_import("story::market", "barter")],
);
let files = [(FileId(0), &barter), (FileId(1), &main)];
let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
haggle_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
haggle_id,
SymbolKind::Knot,
"haggle",
Some("story::market::barter"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e088: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E088)
.collect();
assert!(
e088.is_empty(),
"a trailing segment naming a real submodule must not be E088: {diagnostics:?}"
);
}
#[test]
fn dual_reading_trailing_segment_resolving_to_neither_is_e088() {
let barter = hir_with_module("story::market::barter");
let main = hir_with_module_and_imports(
"story::main",
vec![bare_import("story::market", "nonexistent")],
);
let files = [(FileId(0), &barter), (FileId(1), &main)];
let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
haggle_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
haggle_id,
SymbolKind::Knot,
"haggle",
Some("story::market::barter"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e088: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E088)
.collect();
assert_eq!(
e088.len(),
1,
"a trailing segment naming neither an export nor a submodule must diagnose (the \
retired silent no-op): {diagnostics:?}"
);
}
#[test]
fn dual_reading_both_item_and_submodule_neither_is_suppressed() {
let market = hir_with_module("story::market");
let barter = hir_with_module("story::market::barter");
let main = hir_with_module_and_imports(
"story::main",
vec![bare_import("story::market", "barter")],
);
let files = [
(FileId(0), &market),
(FileId(1), &barter),
(FileId(2), &main),
];
let mut index = SymbolIndex::default();
let item_id = DefinitionId::new(DefinitionTag::Address, 1);
index.symbols.insert(
item_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
item_id,
SymbolKind::Knot,
"barter",
Some("story::market"),
None,
)
},
);
let haggle_id = DefinitionId::new(DefinitionTag::Address, 2);
index.symbols.insert(
haggle_id,
SymbolInfo {
file: FileId(1),
visibility: Visibility::Public,
..symbol(
haggle_id,
SymbolKind::Knot,
"haggle",
Some("story::market::barter"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e088: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E088)
.collect();
assert!(
e088.is_empty(),
"a trailing segment that resolves as both an item and a module must not diagnose: \
{diagnostics:?}"
);
}
#[test]
fn dual_reading_self_import_via_leaf_form_is_e090() {
let barter = hir_with_module_and_imports(
"story::market::barter",
vec![bare_import("story::market", "barter")],
);
let files = [(FileId(0), &barter)];
let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
haggle_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
haggle_id,
SymbolKind::Knot,
"haggle",
Some("story::market::barter"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e090: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E090)
.collect();
assert_eq!(
e090.len(),
1,
"the leaf-item form naming this file's own module must self-import exactly as the \
qualified form does: {diagnostics:?}"
);
}
#[test]
fn parent_module_importing_its_own_declared_submodule_is_not_e090() {
let barter = hir_with_module("story::market::barter");
let market = hir_with_module_and_imports(
"story::market",
vec![bare_import("story::market", "barter")],
);
let files = [(FileId(0), &barter), (FileId(1), &market)];
let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
haggle_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
haggle_id,
SymbolKind::Knot,
"haggle",
Some("story::market::barter"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
assert!(
diagnostics.is_empty(),
"a parent module importing its own declared child submodule must diagnose nothing \
(no E090, and the submodule licenses `haggle` so no E088 either): {diagnostics:?}"
);
}
#[test]
fn aliased_trailing_segment_resolving_to_a_submodule_is_e129() {
let barter = hir_with_module("story::market::barter");
let main = hir_with_module_and_imports(
"story::main",
vec![bare_import_with_alias("story::market", "barter", "b")],
);
let files = [(FileId(0), &barter), (FileId(1), &main)];
let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
haggle_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
haggle_id,
SymbolKind::Knot,
"haggle",
Some("story::market::barter"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e129: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E129)
.collect();
assert_eq!(
e129.len(),
1,
"aliasing a trailing segment that resolves as a module must diagnose E129, not \
silently drop the alias: {diagnostics:?}"
);
}
#[test]
fn unexported_import_still_diagnoses_e088_with_dual_reading_in_place() {
let quest = hir_with_module("quest");
let town = hir_with_module_and_imports("town", vec![bare_import("quest", "ambush")]);
let files = [(FileId(0), &quest), (FileId(1), &town)];
let other_id = DefinitionId::new(DefinitionTag::Address, 1);
let mut index = SymbolIndex::default();
index.symbols.insert(
other_id,
SymbolInfo {
file: FileId(0),
visibility: Visibility::Public,
..symbol(
other_id,
SymbolKind::Knot,
"guard_talk",
Some("quest"),
None,
)
},
);
let diagnostics = check(&files, &index, &Vec::new());
let e088: Vec<_> = diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E088)
.collect();
assert_eq!(
e088.len(),
1,
"an unexported ink import must still diagnose E088, unaffected by dual-reading: \
{diagnostics:?}"
);
}
}