brick/tests/languages.rs
trvon 9f9fc560b7
fix(scan): decode non-UTF-8 source lossily instead of dropping the file
A single invalid byte, such as a Latin-1 `é` in a comment, made
`read_to_string` fail and the whole file and its symbols were skipped.

Files are now read as bytes: a NUL byte still marks a binary file,
which is skipped with a diagnostic; otherwise invalid UTF-8 is decoded
lossily (U+FFFD) and a warning diagnostic records it, so the file keeps
its symbols and calls.

Test: languages::a_non_utf8_byte_does_not_drop_the_whole_file fails
before this change (latin1.rs has no symbols).
2026-09-24 20:13:01 -06:00

731 lines
23 KiB
Rust

use brick::model::{EdgeKind, Node, NodeKind};
use brick::{Building, scan};
use std::fs;
fn project(files: &[(&str, &str)]) -> (tempfile::TempDir, Building) {
let dir = tempfile::tempdir().unwrap();
for (path, text) in files {
let dest = dir.path().join(path);
fs::create_dir_all(dest.parent().unwrap()).unwrap();
fs::write(dest, text).unwrap();
}
let model = scan::scan(dir.path()).unwrap();
model.validate_current_schema().unwrap();
(dir, model)
}
fn symbol<'a>(model: &'a Building, path: &str, name: &str) -> &'a Node {
let nodes: Vec<_> = model
.nodes
.iter()
.filter(|n| {
n.path.as_deref() == Some(path)
&& n.name == name
&& matches!(n.kind, NodeKind::Function | NodeKind::Class)
})
.collect();
assert_eq!(nodes.len(), 1, "expected one {path}:{name}");
nodes[0]
}
fn calls(model: &Building, source: &Node, target: &Node) -> bool {
model
.edges
.iter()
.any(|e| e.kind == EdgeKind::Calls && e.source == source.id && e.target == target.id)
}
#[test]
fn relative_imports_are_source_relative_and_never_guess_a_missing_path() {
for (extension, import) in [
("ts", "import { work } from"),
("js", "import { work } from"),
("dart", "import"),
] {
let main = format!("a/main.{extension}");
let local = format!("a/util.{extension}");
let other = format!("b/util.{extension}");
let specifier = if extension == "dart" {
"./util.dart"
} else {
"./util"
};
let text = format!("{import} '{specifier}';\n{import} './missing/util';\n");
let (_, model) = project(&[(&main, &text), (&local, ""), (&other, "")]);
let imports: Vec<_> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Imports)
.map(|e| model.nodes.iter().find(|n| n.id == e.target).unwrap())
.collect();
assert_eq!(imports.len(), 2);
assert!(imports.iter().any(|n| n.path.as_deref() == Some(&local)));
assert!(
imports
.iter()
.any(|n| n.kind == NodeKind::External && n.name == "./missing/util")
);
}
let (_, model) = project(&[("a/main.ts", "import './missing/util';"), ("b/util.ts", "")]);
assert!(
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Imports)
.all(|e| model
.nodes
.iter()
.any(|n| n.id == e.target && n.kind == NodeKind::External))
);
}
#[test]
fn resolution_respects_language_and_lexical_owners() {
let (_, model) = project(&[
(
"main.rs",
"fn caller() { foreign(); local(); }\nfn local() {}",
),
("other.js", "function foreign() {}\nfunction local() {}"),
(
"types.dart",
"class A { void run() { helper(); } void helper() {} }\nclass B { void helper() {} }",
),
]);
let caller = symbol(&model, "main.rs", "caller");
assert!(calls(&model, caller, symbol(&model, "main.rs", "local")));
assert!(!calls(
&model,
caller,
symbol(&model, "other.js", "foreign")
));
let a = symbol(&model, "types.dart", "A");
let helper = model
.nodes
.iter()
.find(|n| n.name == "helper" && n.parent.as_deref() == Some(&a.id))
.unwrap();
assert!(calls(&model, symbol(&model, "types.dart", "run"), helper));
}
#[test]
fn cpp_inline_definitions_and_constructors_are_symbols_not_calls() {
let (_, model) = project(&[(
"main.cpp",
"void helper() {}\nclass Worker {\npublic:\n Worker() { helper(); }\n void run() { helper(); }\n};\nint main() { helper(); return 0; }",
)]);
let class = model
.nodes
.iter()
.find(|n| n.kind == NodeKind::Class && n.name == "Worker")
.unwrap();
let constructor = model
.nodes
.iter()
.find(|n| n.kind == NodeKind::Function && n.name == "Worker")
.unwrap();
assert_eq!(constructor.parent.as_deref(), Some(class.id.as_str()));
let helper = symbol(&model, "main.cpp", "helper");
for source in [
constructor,
symbol(&model, "main.cpp", "run"),
symbol(&model, "main.cpp", "main"),
] {
assert!(calls(&model, source, helper));
}
assert_eq!(
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.count(),
3
);
}
#[test]
fn javascript_and_typescript_declarations_have_correct_call_owners() {
for ext in ["js", "ts", "tsx"] {
let path = format!("main.{ext}");
let (_, model) = project(&[(
&path,
"export const arrow = () => target();\nexport default function main() { target(); }\nclass Service { run() { target(); } }\nfunction target() {}",
)]);
let target = symbol(&model, &path, "target");
for name in ["arrow", "main", "run"] {
assert!(calls(&model, symbol(&model, &path, name), target));
}
assert_eq!(
symbol(&model, &path, "run").parent.as_deref(),
Some(symbol(&model, &path, "Service").id.as_str())
);
assert_eq!(
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.count(),
3
);
}
let (_, model) = project(&[(
"main.ts",
"export interface Service { run(): void; }\nexport function identity<T>(x: T): T { return x; }\nexport const arrow = () => identity(1);",
)]);
symbol(&model, "main.ts", "Service");
assert!(calls(
&model,
symbol(&model, "main.ts", "arrow"),
symbol(&model, "main.ts", "identity")
));
assert_eq!(
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.count(),
1
);
}
#[test]
fn dart_multiline_method_owns_its_body_calls() {
let (_, model) = project(&[(
"main.dart",
"class Service {\n Future<void> run(\n int value,\n ) async {\n helper();\n }\n void helper() {}\n}",
)]);
let run = symbol(&model, "main.dart", "run");
assert_eq!(
run.parent.as_deref(),
Some(symbol(&model, "main.dart", "Service").id.as_str())
);
assert!(calls(&model, run, symbol(&model, "main.dart", "helper")));
assert_eq!(
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.count(),
1
);
}
#[test]
fn rust_impl_owners_and_const_functions_survive_unrelated_changes() {
let (dir, before) = project(&[(
"main.rs",
"struct A;\nstruct B;\nimpl A { fn run(&self) {} }\nimpl B { fn run(&self) {} }\nconst fn constant() -> u32 { 1 }",
)]);
symbol(&before, "main.rs", "constant");
for owner in ["A", "B"] {
let class = symbol(&before, "main.rs", owner);
assert_eq!(
before
.nodes
.iter()
.filter(|n| n.name == "run" && n.parent.as_deref() == Some(&class.id))
.count(),
1
);
}
assert!(!before.edges.iter().any(|e| e.kind == EdgeKind::Calls));
let path = dir.path().join("main.rs");
fs::write(
&path,
format!(
"// comment\nstruct Unrelated;\n{}",
fs::read_to_string(&path).unwrap()
),
)
.unwrap();
let after = scan::scan(dir.path()).unwrap();
for node in before
.nodes
.iter()
.filter(|n| matches!(n.kind, NodeKind::Class | NodeKind::Function))
{
assert!(after.nodes.iter().any(|n| n.id == node.id));
}
}
#[test]
fn block_comments_and_strings_cannot_create_symbols_or_calls() {
for (ext, code) in [
(
"rs",
"/* fn ghost() { phantom(); } */\nfn real() { let s = r#\"ghost();\"#; }",
),
(
"cpp",
"/* void ghost() { phantom(); } */\nvoid real() { auto s = R\"(ghost();)\"; }",
),
(
"js",
"/* function ghost() { phantom(); } */\nfunction real() { const s = `ghost();`; }",
),
(
"ts",
"/* function ghost() { phantom(); } */\nfunction real() { const s = 'https://example/ghost()'; }",
),
(
"dart",
"/* void ghost() { phantom(); } */\nvoid real() { final s = '''ghost();'''; }",
),
] {
let path = format!("main.{ext}");
let (_, model) = project(&[(&path, code)]);
symbol(&model, &path, "real");
assert!(
!model
.nodes
.iter()
.any(|n| ["ghost", "phantom"].contains(&n.name.as_str())),
"{ext}"
);
assert!(
!model.edges.iter().any(|e| e.kind == EdgeKind::Calls),
"{ext}"
);
}
}
#[cfg(unix)]
#[test]
fn symlinks_are_skipped_with_diagnostics_including_cycles_and_source_files() {
use std::os::unix::fs::symlink;
let (dir, _) = project(&[("src/real.rs", "fn real() {}")]);
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("secret.rs"), "fn secret() {}").unwrap();
symlink(outside.path(), dir.path().join("outside")).unwrap();
symlink(dir.path(), dir.path().join("cycle")).unwrap();
symlink(
outside.path().join("secret.rs"),
dir.path().join("secret.rs"),
)
.unwrap();
symlink(dir.path().join("src"), dir.path().join("alias")).unwrap();
let model = scan::scan(dir.path()).unwrap();
assert_eq!(
model
.nodes
.iter()
.filter(|n| n.kind == NodeKind::File)
.count(),
1
);
assert_eq!(model.diagnostics.len(), 4);
assert!(
model
.diagnostics
.iter()
.all(|d| d.message.contains("symlink") && !d.path.as_deref().unwrap().starts_with('/'))
);
}
#[test]
fn namespaces_do_not_cross_resolve_and_do_not_renumber_unrelated_ids() {
let (dir, before) = project(&[(
"main.cpp",
"namespace a { void helper() {} void run() { helper(); } }\nnamespace b { void helper() {} }",
)]);
let run = symbol(&before, "main.cpp", "run");
let helper = before
.nodes
.iter()
.find(|n| {
n.name == "helper" && n.attributes.get("source.scope") == Some(&serde_json::json!("a"))
})
.unwrap();
assert!(calls(&before, run, helper));
let path = dir.path().join("main.cpp");
fs::write(
&path,
format!(
"namespace unrelated {{ void helper() {{}} }}\n{}",
fs::read_to_string(&path).unwrap()
),
)
.unwrap();
let after = scan::scan(dir.path()).unwrap();
assert!(after.nodes.iter().any(|n| n.id == helper.id));
}
#[test]
fn explicitly_imported_named_functions_resolve_without_global_name_guessing() {
let (_, model) = project(&[
(
"app/main.ts",
"import { work as execute } from './util';\nexport function run() { execute(); missing(); }",
),
("app/util.ts", "export function work() {}"),
(
"unrelated/util.ts",
"export function work() {}\nexport function missing() {}",
),
]);
let run = symbol(&model, "app/main.ts", "run");
assert!(calls(&model, run, symbol(&model, "app/util.ts", "work")));
assert!(!calls(
&model,
run,
symbol(&model, "unrelated/util.ts", "missing")
));
}
#[test]
fn malformed_source_reports_errors_without_inventing_calls_from_recovery_text() {
let (_, model) = project(&[(
"main.ts",
"function complete() {}\nfunction broken( { !!! phantom()",
)]);
symbol(&model, "main.ts", "complete");
assert!(!model.diagnostics.is_empty());
assert!(!model.nodes.iter().any(|n| n.name == "phantom"));
}
#[test]
fn imports_never_resolve_to_nonexported_or_type_only_symbols() {
let (_, model) = project(&[
(
"main.ts",
"import { hidden } from './util';\nimport type { declared } from './util';\nfunction run() { hidden(); declared(); }",
),
(
"util.ts",
"function hidden() {}\nexport function declared() {}",
),
]);
let run = symbol(&model, "main.ts", "run");
for name in ["hidden", "declared"] {
assert!(!calls(&model, run, symbol(&model, "util.ts", name)));
}
}
#[test]
fn member_calls_stay_heuristic_and_unresolved_in_all_languages() {
for (ext, code) in [
("cpp", "void helper() {}\nvoid run() { object.helper(); }"),
(
"js",
"function helper() {}\nfunction run() { object.helper(); }",
),
(
"ts",
"function helper() {}\nfunction run() { object.helper(); }",
),
("dart", "void helper() {}\nvoid run() { object.helper(); }"),
("rs", "fn helper() {}\nfn run() { object.helper(); }"),
] {
let path = format!("main.{ext}");
let (_, model) = project(&[(&path, code)]);
assert!(!calls(
&model,
symbol(&model, &path, "run"),
symbol(&model, &path, "helper")
));
let edge = model
.edges
.iter()
.find(|e| e.kind == EdgeKind::Calls)
.unwrap();
assert!(
edge.evidence
.iter()
.all(|e| e.lane == brick::model::EvidenceLane::Heuristic
&& e.confidence == Some(brick::model::Confidence::Score(0.65))
&& e.provenance.starts_with(&path))
);
}
}
#[test]
fn runtime_parameter_bindings_do_not_resolve_to_same_named_functions() {
for (ext, code) in [
(
"js",
"function helper() {}\nfunction run(helper) { helper(); }",
),
(
"ts",
"function helper() {}\nfunction run(helper: () => void) { helper(); }",
),
("rs", "fn helper() {}\nfn run(helper: fn()) { helper(); }"),
(
"dart",
"void helper() {}\nvoid run(void Function() helper) { helper(); }",
),
(
"cpp",
"void helper() {}\nvoid run(void (*helper)()) { helper(); }",
),
] {
let path = format!("main.{ext}");
let (_, model) = project(&[(&path, code)]);
assert!(
!calls(
&model,
symbol(&model, &path, "run"),
symbol(&model, &path, "helper")
),
"{ext}"
);
}
}
#[test]
fn qualified_rust_module_calls_use_exact_paths_and_visibility() {
let (_, model) = project(&[
(
"src/api.rs",
"fn run() { crate::storage::query(); crate::other::query(); crate::storage::hidden(); }",
),
("src/storage.rs", "pub fn query() {}\nfn hidden() {}"),
("unrelated/storage.rs", "pub fn query() {}"),
]);
let run = symbol(&model, "src/api.rs", "run");
assert!(calls(
&model,
run,
symbol(&model, "src/storage.rs", "query")
));
assert!(!calls(
&model,
run,
symbol(&model, "src/storage.rs", "hidden")
));
assert!(!calls(
&model,
run,
symbol(&model, "unrelated/storage.rs", "query")
));
}
#[test]
fn unresolved_impl_cannot_use_a_method_local_type_as_its_own_owner() {
let (_, model) = project(&[("main.rs", "impl Missing { fn run() { struct Missing; } }")]);
model.validate_current_schema().unwrap();
assert!(
model
.diagnostics
.iter()
.any(|d| d.message.contains("unresolved impl owner"))
);
}
#[test]
fn cpp_parent_relative_includes_resolve_without_basename_fallback() {
let (_, model) = project(&[
(
"src/main.cpp",
"#include \"../include/base.h\"\n#include \"../missing/base.h\"\n",
),
("include/base.h", "struct Base {};"),
]);
let imports: Vec<_> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Imports)
.map(|e| model.nodes.iter().find(|n| n.id == e.target).unwrap())
.collect();
assert_eq!(imports.len(), 2);
assert!(
imports
.iter()
.any(|n| n.path.as_deref() == Some("include/base.h"))
);
assert!(
imports
.iter()
.any(|n| n.kind == NodeKind::External && n.name == "../missing/base.h")
);
}
#[test]
fn generic_type_arguments_are_not_base_classes() {
// `extends Bloc<Ev, St>` produced inherits edges to Ev and St: the base walker
// descended into the type-argument list. On pipe 39 of 145 Dart inherits edges
// (27%) pointed at type arguments. C++ `: public Base<Arg>` shared the walker.
let (_, model) = project(&[
(
"lib/bloc.dart",
"class Ev {}\nclass St {}\nclass Bloc<E, S> {}\nclass MyBloc extends Bloc<Ev, St> {}\n",
),
(
"src/base.cpp",
"template <class T> class Base {};\nclass Arg {};\nclass Derived : public Base<Arg> {};\n",
),
]);
let inherits = |source: &Node| {
let mut names: Vec<String> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Inherits && e.source == source.id)
.map(|e| {
model
.nodes
.iter()
.find(|n| n.id == e.target)
.unwrap()
.name
.clone()
})
.collect();
names.sort();
names
};
assert_eq!(
inherits(symbol(&model, "lib/bloc.dart", "MyBloc")),
["Bloc"]
);
assert_eq!(
inherits(symbol(&model, "src/base.cpp", "Derived")),
["Base"]
);
}
#[test]
fn python_calls_keep_their_function_and_resolve_only_what_is_visible() {
let (_, model) = project(&[
(
"pkg/a.py",
"def process(x):\n return x\n\ndef unrelated():\n pass\n",
),
(
"pkg/b.py",
"class Base:\n pass\n\ndef helper():\n pass\n",
),
(
"pkg/c.py",
// A blank line and a comment inside the body used to pop the function
// scope, attributing the later calls to the file.
"from pkg.b import Base\n\nclass Child(Base):\n def go(self):\n x = 1\n\n # comment\n helper()\n process(1)\n unrelated()\n",
),
(
"pkg/d.py",
"from pkg.a import process\n\ndef use():\n return process(2)\n",
),
]);
let go = symbol(&model, "pkg/c.py", "go");
let outgoing = |source: &Node| {
let mut found: Vec<(String, String)> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.source == source.id)
.map(|e| {
let target = model.nodes.iter().find(|n| n.id == e.target).unwrap();
(target.name.clone(), target.path.clone().unwrap_or_default())
})
.collect();
found.sort();
found
};
// helper comes from the imported pkg/b.py; process and unrelated are never
// imported by c.py, so they stay external instead of resolving by unique name.
assert_eq!(
outgoing(go),
[
("helper".to_owned(), "pkg/b.py".to_owned()),
("process".to_owned(), String::new()),
("unrelated".to_owned(), String::new()),
]
);
// An imported name still resolves across files.
assert_eq!(
outgoing(symbol(&model, "pkg/d.py", "use")),
[("process".to_owned(), "pkg/a.py".to_owned())]
);
// A class header is not a call.
let file_c = model
.nodes
.iter()
.find(|n| n.kind == NodeKind::File && n.path.as_deref() == Some("pkg/c.py"))
.unwrap();
assert!(
!model
.edges
.iter()
.any(|e| e.kind == EdgeKind::Calls && e.source == file_c.id),
"file-level call edges in pkg/c.py"
);
}
#[test]
fn dart_calls_resolve_through_imported_libraries() {
// Dart imports expose a library's public top-level names without a binding per
// name, so no cross-file Dart call ever resolved even when its import did.
let (_, model) = project(&[
("pubspec.yaml", "name: demo\n"),
(
"lib/util.dart",
"int helper() => 1;\nint _secret() => 2;\nclass Tool { int use() => 3; }\n",
),
("lib/other.dart", "int shared() => 4;\n"),
("lib/twin.dart", "int shared() => 5;\n"),
(
"lib/main.dart",
"import 'util.dart';\nimport 'package:demo/other.dart';\nimport 'twin.dart';\nvoid run() {\n helper();\n _secret();\n shared();\n}\n",
),
// Clean negative: no import, no edge.
("lib/stranger.dart", "void stray() {\n helper();\n}\n"),
]);
let targets = |source: &Node| {
let mut found: Vec<(String, String, String)> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.source == source.id)
.map(|e| {
let target = model.nodes.iter().find(|n| n.id == e.target).unwrap();
(
target.name.clone(),
target.path.clone().unwrap_or_default(),
e.attributes
.get("source.unresolved-reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
)
})
.collect();
found.sort();
found
};
assert_eq!(
targets(symbol(&model, "lib/main.dart", "run")),
[
("_secret".into(), String::new(), "not-visible".into()),
("helper".into(), "lib/util.dart".into(), String::new()),
("shared".into(), String::new(), "ambiguous".into()),
]
);
assert_eq!(
targets(symbol(&model, "lib/stranger.dart", "stray")),
[("helper".into(), String::new(), "not-visible".into())]
);
}
#[test]
fn a_non_utf8_byte_does_not_drop_the_whole_file() {
// One Latin-1 `é` in a comment made the whole file (and its functions) vanish.
let dir = tempfile::tempdir().unwrap();
let mut source = b"// caf".to_vec();
source.push(0xE9);
source.extend_from_slice(b"\nfn kept() {}\nfn caller() { kept(); }\n");
fs::write(dir.path().join("latin1.rs"), source).unwrap();
fs::write(dir.path().join("blob.rs"), [0u8, 159, 146, 150]).unwrap();
let model = scan::scan(dir.path()).unwrap();
let caller = symbol(&model, "latin1.rs", "caller");
assert!(calls(&model, caller, symbol(&model, "latin1.rs", "kept")));
assert!(
model
.diagnostics
.iter()
.any(|d| d.path.as_deref() == Some("latin1.rs") && d.message.contains("non-UTF-8"))
);
// A file with NUL bytes is binary, not source: still skipped with a diagnostic.
assert!(
!model
.nodes
.iter()
.any(|n| n.path.as_deref() == Some("blob.rs"))
);
}