fix: review follow-ups for #5 and the Swift lexical preview #8

Merged
trevon merged 9 commits from fix/review-followups into main 2026-09-25 13:33:41 +00:00
4 changed files with 172 additions and 2 deletions
Showing only changes of commit 324900287b - Show all commits

fix(dart): honour import prefixes, show/hide and one level of export
Some checks failed
build / build-macos (pull_request) Has been cancelled
build / release (pull_request) Has been cancelled
build / build-linux (pull_request) Has been cancelled

Problem: a bare call in a Dart file resolved against every imported
library. An `import 'a.dart' as p;` still bound `foo()` to a.dart's foo,
`hide` and `show` combinators were ignored, and a name re-exported through
a barrel library (`export 'impl.dart';`) never resolved.

Fix: record each Dart import/export directive with its `as` prefix and
show/hide lists. A bare name resolves only in libraries imported without a
prefix whose combinators let it through, plus that library's own exports
that also let it through (one level).

Test: tests/dart_imports.rs covers a prefixed, a hiding, a showing and a
barrelled import; it failed on the prefixed case before this change.
trvon 2026-09-24 21:11:48 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -174,7 +174,8 @@ directory (Dart `package:<own package>/` against `lib/`); missing paths never
fall back to unrelated basenames. Explicit JS/TS named/default import bindings,
module-scoped Rust grouped/aliased imports, module-qualified Rust public calls,
and public top-level Dart names from imported libraries can resolve across files
(Dart `show`/`hide` combinators are not modelled). The lexical preview resolves
(Dart `show`/`hide` are honoured, `as`-prefixed imports never bind bare names,
and a library's own `export`s are followed one level). The lexical preview resolves
Python names in the calling file, then in files it imports; Swift names in the
calling file, then by a module-wide unique name (Swift files of one module see
each other without imports).

View file

@ -252,6 +252,8 @@ struct ScanState {
/// Imports edges by source file id, built once on first use: scanning every
/// node and edge per file made resolution O(files x (nodes + edges)).
imports_by_file: Option<HashMap<String, Vec<String>>>,
/// Dart file -> its import/export directives (prefixes, show/hide, exports).
dart_directives: HashMap<String, Vec<crate::source::DartDirective>>,
/// `name:` of the root pubspec.yaml, so `package:<name>/x.dart` maps to lib/x.dart.
dart_package: Option<String>,
}
@ -288,6 +290,7 @@ impl ScanState {
references: HashMap::new(),
imported_paths: HashMap::new(),
imports_by_file: None,
dart_directives: HashMap::new(),
}
}
@ -639,6 +642,10 @@ impl ScanState {
return;
}
};
if language == "dart" {
self.dart_directives
.insert(path.to_owned(), document.dart_directives.clone());
}
for message in &document.diagnostics {
self.diagnostics.push(Diagnostic {
level: "warning".into(),
@ -1356,6 +1363,39 @@ impl ScanState {
paths
}
/// Libraries whose public top-level `name` a Dart file can call bare: imports
/// without an `as` prefix whose `show`/`hide` let `name` through, plus one level
/// of those libraries' own `export`s that also let it through.
fn dart_visible_libraries(&self, path: &str, name: &str) -> HashSet<String> {
let library_of = |target: &str, from: &str| {
self.resolve_import(target, "dart", from)
.and_then(|id| self.node(&id))
.and_then(|node| node.path.clone())
};
let mut visible = HashSet::new();
for directive in self.dart_directives.get(path).into_iter().flatten() {
if directive.export || directive.prefix.is_some() || !directive.allows(name) {
continue;
}
let Some(library) = library_of(&directive.target, path) else {
continue;
};
for export in self
.dart_directives
.get(&library)
.into_iter()
.flatten()
.filter(|export| export.export && export.allows(name))
{
if let Some(exported) = library_of(&export.target, &library) {
visible.insert(exported);
}
}
visible.insert(library);
}
visible
}
/// Mark functions that may be used without a resolvable call: named outside
/// a call position anywhere in the language, or C++ virtual via a prototype.
fn annotate_indirect_uses(&mut self) {
@ -1434,8 +1474,10 @@ impl ScanState {
if let Some(id) = self.resolve_qualified_symbol(relationship) {
return (id, None, None);
}
let imported = if language == "python" || language == "dart" {
let imported = if language == "python" {
self.imported_paths(&relationship.path)
} else if language == "dart" {
Rc::new(self.dart_visible_libraries(&relationship.path, name))
} else {
Rc::default()
};

View file

@ -38,6 +38,25 @@ pub(crate) struct Declaration {
pub preprocessor_alternative: bool,
}
/// A Dart `import` or `export` directive and what it exposes.
#[derive(Clone, Debug)]
pub(crate) struct DartDirective {
pub target: String,
/// `as p`: names are reachable only as `p.name`, never bare.
pub prefix: Option<String>,
pub show: Vec<String>,
pub hide: Vec<String>,
pub export: bool,
}
impl DartDirective {
/// The directive's combinators let `name` through.
pub fn allows(&self, name: &str) -> bool {
(self.show.is_empty() || self.show.iter().any(|shown| shown == name))
&& !self.hide.iter().any(|hidden| hidden == name)
}
}
pub(crate) struct ImportBinding {
pub local: String,
pub exported: String,
@ -107,6 +126,8 @@ pub(crate) struct Document {
pub diagnostics: Vec<String>,
pub backend: String,
pub bindings: Vec<ImportBinding>,
/// Dart import/export directives with their prefixes and combinators.
pub dart_directives: Vec<DartDirective>,
/// C++ qualified names declared (prototype or definition) by this file.
pub declared: Vec<String>,
/// C/C++ preprocessor macro names defined by this file.
@ -221,6 +242,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
diagnostics: Vec::new(),
backend: format!("tree-sitter-{version}:brick-source-v1"),
bindings: Vec::new(),
dart_directives: Vec::new(),
declared: Vec::new(),
macros: Vec::new(),
parse_incomplete: false,
@ -669,6 +691,10 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
{
doc.bindings.extend(import_bindings(node, &target, source));
}
if language == "dart" {
doc.dart_directives
.push(dart_directive(node, &target, source));
}
doc.relationships.push(Relationship {
owner: None,
target,
@ -1098,6 +1124,35 @@ fn parameter_shadows<'tree>(
false
}
fn dart_directive(node: Node<'_>, target: &str, source: &str) -> DartDirective {
let mut directive = DartDirective {
target: target.to_owned(),
prefix: node
.child_by_field_name("alias")
.map(|alias| text(alias, source).to_owned()),
show: Vec::new(),
hide: Vec::new(),
export: node.kind() == "library_export",
};
let mut cursor = node.walk();
for combinator in node
.named_children(&mut cursor)
.filter(|child| child.kind() == "combinator")
{
let mut names = combinator.walk();
let identifiers = combinator
.named_children(&mut names)
.filter(|child| child.kind() == "identifier")
.map(|identifier| text(identifier, source).to_owned());
if text(combinator, source).trim_start().starts_with("hide") {
directive.hide.extend(identifiers);
} else {
directive.show.extend(identifiers);
}
}
directive
}
fn import_bindings(node: Node<'_>, module: &str, source: &str) -> Vec<ImportBinding> {
let mut result = Vec::new();
// Type-only imports must not authorize runtime call resolution.

72
tests/dart_imports.rs Normal file
View file

@ -0,0 +1,72 @@
use brick::model::{Building, EdgeKind};
use brick::scan;
use std::fs;
fn scan_files(files: &[(&str, &str)]) -> Building {
let root = tempfile::tempdir().unwrap();
for (path, content) in files {
let file = root.path().join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(file, content).unwrap();
}
scan::scan(root.path()).unwrap()
}
/// Paths of the resolved targets of calls leaving functions named `caller`.
fn call_targets(model: &Building, caller: &str) -> Vec<String> {
model
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Calls)
.filter(|edge| {
model
.nodes
.iter()
.any(|n| n.id == edge.source && n.name == caller)
})
.filter_map(|edge| {
let target = model.nodes.iter().find(|n| n.id == edge.target)?;
Some(format!("{}:{}", target.path.clone()?, target.name))
})
.collect()
}
#[test]
fn dart_import_prefixes_combinators_and_exports_decide_what_a_bare_name_binds() {
// Dart calls resolved through every imported library, ignoring `as` prefixes
// and `show`/`hide`, and never through a library's own `export`s.
let model = scan_files(&[
("lib/a.dart", "void foo() {}\n"),
("lib/h.dart", "void hidden() {}\nvoid visible() {}\n"),
("lib/s.dart", "void shown() {}\nvoid other() {}\n"),
("lib/impl.dart", "void exported() {}\n"),
("lib/barrel.dart", "export 'impl.dart';\n"),
(
"lib/prefixed.dart",
"import 'a.dart' as a;\nvoid usePrefix() { foo(); }\n",
),
(
"lib/hiding.dart",
"import 'h.dart' hide hidden;\nvoid useHide() { hidden(); visible(); }\n",
),
(
"lib/showing.dart",
"import 's.dart' show shown;\nvoid useShow() { shown(); other(); }\n",
),
(
"lib/barrelled.dart",
"import 'barrel.dart';\nvoid useExport() { exported(); }\n",
),
]);
assert!(
call_targets(&model, "usePrefix").is_empty(),
"{:?}",
call_targets(&model, "usePrefix")
);
assert_eq!(call_targets(&model, "useHide"), ["lib/h.dart:visible"]);
assert_eq!(call_targets(&model, "useShow"), ["lib/s.dart:shown"]);
assert_eq!(
call_targets(&model, "useExport"),
["lib/impl.dart:exported"]
);
}