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
11 changed files with 801 additions and 55 deletions

View file

@ -22,7 +22,9 @@ tests.
named and default import bindings resolve across files. named and default import bindings resolve across files.
- **Dart:** relative imports resolve against the importing directory, and - **Dart:** relative imports resolve against the importing directory, and
`package:<own package>/` against `lib/`. Public top-level names from imported `package:<own package>/` against `lib/`. Public top-level names from imported
libraries resolve across files; `show`/`hide` combinators are not modelled. libraries resolve across files: `show`/`hide` are honoured, `as`-prefixed
imports never bind bare names, and a library's own `export`s are followed one
level.
- **Rust:** module-scoped grouped and aliased imports and module-qualified public - **Rust:** module-scoped grouped and aliased imports and module-qualified public
calls resolve. Types are identified by their declaring file, so same-named calls resolve. Types are identified by their declaring file, so same-named
types in different modules resolve through the caller's `use` binding or stay types in different modules resolve through the caller's `use` binding or stay

View file

@ -31,16 +31,18 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
.collect(), .collect(),
None => vec![value.pointer("/tool/name").and_then(Value::as_str)], None => vec![value.pointer("/tool/name").and_then(Value::as_str)],
}; };
let from_fold = !producers.is_empty() && producers.iter().all(|name| *name == Some("fold")); // Decided per run, not per report: a merged SARIF log with one Fold run and one
if !from_fold { // other tool's run must keep the Fold run's Symbolic lane.
if producers.is_empty() || producers.iter().any(|name| *name != Some("fold")) {
let named: Vec<&str> = producers let named: Vec<&str> = producers
.iter() .iter()
.filter(|name| **name != Some("fold"))
.map(|name| name.unwrap_or("unnamed")) .map(|name| name.unwrap_or("unnamed"))
.collect(); .collect();
building.diagnostics.push(crate::model::Diagnostic { building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(), level: "warning".into(),
message: format!( message: format!(
"report {} was not produced by Fold (producer: {}); its findings are imported, never symbolic", "report {} has findings not produced by Fold (producer: {}); those are imported, never symbolic",
report.display(), report.display(),
named.join(", ") named.join(", ")
), ),
@ -62,7 +64,12 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
let (findings, unlocated) = if sarif_runs.is_some() { let (findings, unlocated) = if sarif_runs.is_some() {
sarif_findings(&value) sarif_findings(&value)
} else { } else {
native_findings(&value) let from_fold = value.pointer("/tool/name").and_then(Value::as_str) == Some("fold");
let (mut findings, unlocated) = native_findings(&value);
for finding in &mut findings {
finding.from_fold = from_fold;
}
(findings, unlocated)
}; };
if unlocated > 0 { if unlocated > 0 {
building.diagnostics.push(crate::model::Diagnostic { building.diagnostics.push(crate::model::Diagnostic {
@ -139,7 +146,7 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
.unwrap_or_else(|| format!("{}:{}:{}", finding.rule, relative_path, finding.line)); .unwrap_or_else(|| format!("{}:{}:{}", finding.rule, relative_path, finding.line));
let node_id = stable_id("finding", &identity); let node_id = stable_id("finding", &identity);
let evidence = Evidence { let evidence = Evidence {
lane: fold_lane(&backend, finding.confidence.as_deref(), from_fold), lane: fold_lane(&backend, finding.confidence.as_deref(), finding.from_fold),
backend: format!("fold:discover:{backend}"), backend: format!("fold:discover:{backend}"),
confidence: finding.confidence.clone().map(Confidence::Label), confidence: finding.confidence.clone().map(Confidence::Label),
provenance: format!("{}:{}", report.display(), finding.line), provenance: format!("{}:{}", report.display(), finding.line),
@ -195,6 +202,8 @@ struct Finding {
line: usize, line: usize,
confidence: Option<String>, confidence: Option<String>,
backend: Option<String>, backend: Option<String>,
/// Produced by Fold (native report tool, or this SARIF run's driver).
from_fold: bool,
} }
/// Findings plus the number dropped for lack of a file location. /// Findings plus the number dropped for lack of a file location.
@ -218,6 +227,7 @@ fn native_findings(value: &Value) -> (Vec<Finding>, usize) {
line: native_line(item), line: native_line(item),
confidence: string(item, ["confidence"]), confidence: string(item, ["confidence"]),
backend: native_backend(item), backend: native_backend(item),
from_fold: false,
}) })
}) })
.collect(); .collect();
@ -227,22 +237,25 @@ fn native_findings(value: &Value) -> (Vec<Finding>, usize) {
/// Findings plus the number dropped for lack of a file location. /// Findings plus the number dropped for lack of a file location.
fn sarif_findings(value: &Value) -> (Vec<Finding>, usize) { fn sarif_findings(value: &Value) -> (Vec<Finding>, usize) {
let items: Vec<&Value> = value let items: Vec<(&Value, bool)> = value
.get("runs") .get("runs")
.and_then(Value::as_array) .and_then(Value::as_array)
.into_iter() .into_iter()
.flatten() .flatten()
.flat_map(|run| { .flat_map(|run| {
let from_fold =
run.pointer("/tool/driver/name").and_then(Value::as_str) == Some("fold");
run.get("results") run.get("results")
.and_then(Value::as_array) .and_then(Value::as_array)
.into_iter() .into_iter()
.flatten() .flatten()
.map(move |item| (item, from_fold))
}) })
.collect(); .collect();
let total = items.len(); let total = items.len();
let findings: Vec<Finding> = items let findings: Vec<Finding> = items
.into_iter() .into_iter()
.filter_map(|item| { .filter_map(|(item, from_fold)| {
let location = item.pointer("/locations/0/physicalLocation")?; let location = item.pointer("/locations/0/physicalLocation")?;
Some(Finding { Some(Finding {
stable_id: item stable_id: item
@ -275,6 +288,7 @@ fn sarif_findings(value: &Value) -> (Vec<Finding>, usize) {
.pointer("/properties/backend") .pointer("/properties/backend")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::to_owned), .map(str::to_owned),
from_fold,
}) })
}) })
.collect(); .collect();
@ -293,11 +307,16 @@ fn sarif_uri_path(uri: &str) -> String {
let mut decoded = Vec::with_capacity(bytes.len()); let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0; let mut i = 0;
while i < bytes.len() { while i < bytes.len() {
// Decode on bytes, and only two hex digits: slicing the &str at `i + 1..i + 3`
// panicked when a multibyte character followed `%`, and from_str_radix
// accepted a leading `+`.
if bytes[i] == b'%' if bytes[i] == b'%'
&& i + 2 < bytes.len() && let (Some(high), Some(low)) = (
&& let Ok(byte) = u8::from_str_radix(&rest[i + 1..i + 3], 16) bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
)
{ {
decoded.push(byte); decoded.push((high * 16 + low) as u8);
i += 3; i += 3;
continue; continue;
} }
@ -348,7 +367,20 @@ fn native_backend(value: &Value) -> Option<String> {
fn find_file(building: &Building, path: &str) -> Option<String> { fn find_file(building: &Building, path: &str) -> Option<String> {
let normalized = normalize_lexical_path(path); let normalized = normalize_lexical_path(path);
let relative = if is_portable_absolute(path) { let relative = if is_portable_absolute(path)
&& path.contains("..")
&& let (Ok(canonical), Ok(root)) = (
Path::new(path).canonicalize(),
Path::new(&building.root).canonicalize(),
) {
// As for relative paths: a `..` in a path that exists here follows the
// filesystem, so `/root/link/../x` names what the OS resolves through `link`.
canonical
.strip_prefix(&root)
.ok()?
.to_str()?
.replace('\\', "/")
} else if is_portable_absolute(path) {
let root = normalize_lexical_path(&building.root); let root = normalize_lexical_path(&building.root);
let prefix = format!("{}/", root.trim_end_matches('/')); let prefix = format!("{}/", root.trim_end_matches('/'));
if let Some(relative) = normalized.strip_prefix(&prefix) { if let Some(relative) = normalized.strip_prefix(&prefix) {
@ -881,4 +913,100 @@ mod tests {
.any(|d| d.message.contains("did not match a scanned file")) .any(|d| d.message.contains("did not match a scanned file"))
); );
} }
#[test]
fn sarif_percent_escapes_decode_only_hex_digits_and_never_panic() {
// `&rest[i + 1..i + 3]` sliced by byte index: a `%` before a multibyte
// character panicked on a non-char boundary, and from_str_radix accepted `+`.
use super::sarif_uri_path;
assert_eq!(sarif_uri_path("file:///src/%aé.c"), "/src/%aé.c");
assert_eq!(sarif_uri_path("file:///src/%+1.c"), "/src/%+1.c");
assert_eq!(sarif_uri_path("file:///src/a%20b.c"), "/src/a b.c");
assert_eq!(sarif_uri_path("file:///src/%C3%A9.c"), "/src/é.c");
assert_eq!(sarif_uri_path("file:///src/%4"), "/src/%4");
}
#[test]
fn merged_sarif_decides_the_symbolic_lane_per_run() {
// The lane was decided for the whole report with all(): one non-Fold run
// in a merged log demoted every Fold finding to Imported.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.cpp"), "int a() { return 0; }\n").unwrap();
fs::write(dir.path().join("b.cpp"), "int b() { return 0; }\n").unwrap();
let result = |file: &str, rule: &str| {
serde_json::json!({"ruleId": rule, "message": {"text": "x"},
"locations": [{"physicalLocation": {"artifactLocation": {"uri": file},
"region": {"startLine": 1}}}],
"properties": {"confidence": "proved", "backend": "source"}})
};
let value = serde_json::json!({
"version": "2.1.0",
"runs": [
{"tool": {"driver": {"name": "fold"}}, "results": [result("a.cpp", "fold-rule")]},
{"tool": {"driver": {"name": "codeql"}}, "results": [result("b.cpp", "codeql-rule")]}
]
});
let report = dir.path().join("merged.sarif");
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
let mut building = scan(dir.path()).unwrap();
assert_eq!(import_findings(&mut building, &report).unwrap(), 2);
let lane_for = |rule: &str| {
let node = building
.nodes
.iter()
.find(|node| node.kind == NodeKind::Finding && node.name == rule)
.unwrap();
building
.edges
.iter()
.find(|edge| edge.source == node.id && edge.kind == EdgeKind::Flags)
.unwrap()
.evidence[0]
.lane
};
assert_eq!(lane_for("fold-rule"), crate::model::EvidenceLane::Symbolic);
assert_eq!(
lane_for("codeql-rule"),
crate::model::EvidenceLane::Imported
);
}
#[cfg(unix)]
#[test]
fn absolute_parent_steps_after_a_symlinked_directory_follow_the_filesystem() {
// The filesystem rule covered only relative paths: `/root/linkout/../src/x`
// (or its file:// form) still normalized lexically into the repository.
use std::os::unix::fs::symlink;
let workspace = tempfile::tempdir().unwrap();
let root = workspace.path().join("repo");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
let outside = workspace.path().join("elsewhere/deeper");
fs::create_dir_all(&outside).unwrap();
fs::create_dir_all(workspace.path().join("elsewhere/src")).unwrap();
fs::write(
workspace.path().join("elsewhere/src/main.rs"),
"fn other() {}\n",
)
.unwrap();
symlink(&outside, root.join("linkout")).unwrap();
let mut model = scan(&root).unwrap();
let absolute = format!(
"{}/linkout/../src/main.rs",
model.root.trim_end_matches('/')
);
for path in [absolute.clone(), format!("file://{absolute}")] {
let report = workspace.path().join("fold.sarif");
let value = serde_json::json!({
"version": "2.1.0",
"runs": [{"tool": {"driver": {"name": "fold"}}, "results": [{
"ruleId": "through-link", "message": {"text": "x"},
"locations": [{"physicalLocation": {"artifactLocation": {"uri": path},
"region": {"startLine": 1}}}],
"properties": {"confidence": "heuristic", "backend": "source"}}]}]
});
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(import_findings(&mut model, &report).unwrap(), 0, "{path}");
}
}
} }

View file

@ -199,7 +199,7 @@ fn run(cli: Cli) -> Result<()> {
json.display(), json.display(),
html.display() html.display()
); );
if !has_source_files(&building) { if !building.has_source_files() {
eprintln!( eprintln!(
"brick: warning: no supported source files were found; the model is empty and its score describes nothing" "brick: warning: no supported source files were found; the model is empty and its score describes nothing"
); );
@ -244,15 +244,19 @@ fn run(cli: Cli) -> Result<()> {
let mut recomputed = building.clone(); let mut recomputed = building.clone();
brick::metrics::calculate(&mut recomputed); brick::metrics::calculate(&mut recomputed);
let mut failures = Vec::new(); let mut failures = Vec::new();
if serde_json::to_value(&recomputed.metrics)? // Compare only fields both copies carry: a model from an older build lacks
!= serde_json::to_value(&building.metrics)? // newer fields (`metrics.resolution`) and may use an older formula. That is
{ // worth a warning, never a failure; the gate below uses recomputed values.
failures.push( if stored_metrics_differ(
"stored metrics do not match the graph; gating on recomputed metrics".into(), &serde_json::to_value(&building.metrics)?,
&serde_json::to_value(&recomputed.metrics)?,
) {
eprintln!(
"brick: warning: stored metrics do not match the graph; gating on recomputed metrics"
); );
} }
let metrics = &recomputed.metrics; let metrics = &recomputed.metrics;
if !has_source_files(&building) { if !building.has_source_files() {
failures.push( failures.push(
"the model contains no scanned source files; nothing was analysed".into(), "the model contains no scanned source files; nothing was analysed".into(),
); );
@ -430,12 +434,20 @@ fn write_json(building: &Building, path: &PathBuf) -> Result<()> {
.with_context(|| format!("cannot write graph {}", path.display())) .with_context(|| format!("cannot write graph {}", path.display()))
} }
/// At least one source file was scanned into the model. /// True when a stored metrics value disagrees with the recomputed one on a field
fn has_source_files(building: &Building) -> bool { /// both carry. Fields missing from the stored copy (written by an older build)
building /// are not compared.
.nodes fn stored_metrics_differ(stored: &serde_json::Value, recomputed: &serde_json::Value) -> bool {
.iter() match (stored, recomputed) {
.any(|node| node.kind == brick::model::NodeKind::File) (serde_json::Value::Object(stored), serde_json::Value::Object(recomputed)) => {
stored.iter().any(|(key, value)| {
recomputed
.get(key)
.is_some_and(|fresh| stored_metrics_differ(value, fresh))
})
}
_ => stored != recomputed,
}
} }
fn read_json(path: &PathBuf) -> Result<Building> { fn read_json(path: &PathBuf) -> Result<Building> {

View file

@ -20,6 +20,12 @@ pub struct Building {
} }
impl Building { impl Building {
/// At least one source file was scanned into the model. The one definition
/// `scan`, `build` and `check` share for "this analysed something".
pub fn has_source_files(&self) -> bool {
has_source_files(&self.nodes)
}
pub fn validate_current_schema(&self) -> Result<(), CompatibilityError> { pub fn validate_current_schema(&self) -> Result<(), CompatibilityError> {
if self.schema_version != SCHEMA_VERSION { if self.schema_version != SCHEMA_VERSION {
return Err(CompatibilityError::SchemaVersion { return Err(CompatibilityError::SchemaVersion {
@ -601,6 +607,11 @@ fn compare_confidence(left: &Option<Confidence>, right: &Option<Confidence>) ->
} }
} }
/// At least one of `nodes` is a scanned source file.
pub fn has_source_files(nodes: &[Node]) -> bool {
nodes.iter().any(|node| node.kind == NodeKind::File)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{

View file

@ -101,7 +101,7 @@ pub fn scan_with_options(root: &Path, options: &ScanOptions) -> Result<Building>
state.resolve_relationships(); state.resolve_relationships();
state.annotate_indirect_uses(); state.annotate_indirect_uses();
// An analysis of nothing must not read as a healthy repository. // An analysis of nothing must not read as a healthy repository.
if !state.nodes.iter().any(|node| node.kind == NodeKind::File) { if !crate::model::has_source_files(&state.nodes) {
state.diagnostics.push(Diagnostic { state.diagnostics.push(Diagnostic {
level: "warning".into(), level: "warning".into(),
message: "no supported source files were found; the model is empty".into(), message: "no supported source files were found; the model is empty".into(),
@ -149,7 +149,8 @@ struct RustCall {
} }
/// A method node id, the trait it implements (if any), and the file of its `impl`. /// A method node id, the trait it implements (if any), and the file of its `impl`.
type RustMethod = (String, Option<String>, String); /// (method id, implemented trait, impl file, impl type path when written as one).
type RustMethod = (String, Option<String>, String, Option<String>);
/// Crate-wide Rust impl/trait facts keyed by leaf type names. Leaf names are not /// Crate-wide Rust impl/trait facts keyed by leaf type names. Leaf names are not
/// identities: when several files declare the same name, `rust_type_file` settles /// identities: when several files declare the same name, `rust_type_file` settles
@ -248,6 +249,11 @@ struct ScanState {
references: HashMap<String, HashSet<String>>, references: HashMap<String, HashSet<String>>,
/// Lexical-language file -> repository files it imports (resolved edges). /// Lexical-language file -> repository files it imports (resolved edges).
imported_paths: HashMap<String, Rc<HashSet<String>>>, imported_paths: HashMap<String, Rc<HashSet<String>>>,
/// 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. /// `name:` of the root pubspec.yaml, so `package:<name>/x.dart` maps to lib/x.dart.
dart_package: Option<String>, dart_package: Option<String>,
} }
@ -283,6 +289,8 @@ impl ScanState {
rust: RustIndex::default(), rust: RustIndex::default(),
references: HashMap::new(), references: HashMap::new(),
imported_paths: HashMap::new(), imported_paths: HashMap::new(),
imports_by_file: None,
dart_directives: HashMap::new(),
} }
} }
@ -452,10 +460,18 @@ impl ScanState {
let patterns = Patterns::new(language); let patterns = Patterns::new(language);
let mut scopes: Vec<Scope> = Vec::new(); let mut scopes: Vec<Scope> = Vec::new();
let mut brace_depth = 0_i32; let mut brace_depth = 0_i32;
let mut comment_depth = 0_usize;
for (index, raw_line) in content.lines().enumerate() { for (index, raw_line) in content.lines().enumerate() {
let line_number = index + 1; let line_number = index + 1;
let line = strip_line_comment(raw_line, language); // Block comments (nested, as Swift allows) span lines; without masking,
// a call inside `/* ... */` became an edge.
let masked = if language == "python" {
raw_line.to_owned()
} else {
mask_block_comments(raw_line, &mut comment_depth)
};
let line = strip_line_comment(&masked, language);
let call_line = mask_quoted_strings(line); let call_line = mask_quoted_strings(line);
let indent = raw_line.len() - raw_line.trim_start().len(); let indent = raw_line.len() - raw_line.trim_start().len();
if language == "python" { if language == "python" {
@ -479,6 +495,7 @@ impl ScanState {
let mut class_name = None; let mut class_name = None;
if let Some(captures) = patterns.class.captures(line) if let Some(captures) = patterns.class.captures(line)
&& let Some(name) = captures.name("name") && let Some(name) = captures.name("name")
&& !(language == "swift" && is_swift_member_keyword(name.as_str()))
{ {
class_name = Some(name.as_str().to_owned()); class_name = Some(name.as_str().to_owned());
let owner_id = scopes let owner_id = scopes
@ -625,6 +642,10 @@ impl ScanState {
return; return;
} }
}; };
if language == "dart" {
self.dart_directives
.insert(path.to_owned(), document.dart_directives.clone());
}
for message in &document.diagnostics { for message in &document.diagnostics {
self.diagnostics.push(Diagnostic { self.diagnostics.push(Diagnostic {
level: "warning".into(), level: "warning".into(),
@ -751,7 +772,12 @@ impl ScanState {
.methods .methods
.entry((owner, declaration.name.clone())) .entry((owner, declaration.name.clone()))
.or_default() .or_default()
.push((id.clone(), implemented, path.to_owned())); .push((
id.clone(),
implemented,
path.to_owned(),
declaration.impl_type_path.clone(),
));
if let Some(returns) = &declaration.return_type { if let Some(returns) = &declaration.return_type {
self.rust.returns.insert(id.clone(), returns.clone()); self.rust.returns.insert(id.clone(), returns.clone());
} }
@ -1310,26 +1336,66 @@ impl ScanState {
if let Some(found) = self.imported_paths.get(path) { if let Some(found) = self.imported_paths.get(path) {
return Rc::clone(found); return Rc::clone(found);
} }
if self.imports_by_file.is_none() {
let mut index: HashMap<String, Vec<String>> = HashMap::new();
for edge in self.edges.iter().filter(|e| e.kind == EdgeKind::Imports) {
index
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
}
self.imports_by_file = Some(index);
}
let file_id = stable_id("file", path);
let paths: HashSet<String> = self let paths: HashSet<String> = self
.nodes .imports_by_file
.iter() .as_ref()
.find(|n| n.kind == NodeKind::File && n.path.as_deref() == Some(path)) .and_then(|index| index.get(&file_id))
.map(|file| { .into_iter()
self.edges .flatten()
.iter() .filter_map(|target| self.node(target))
.filter(|e| e.kind == EdgeKind::Imports && e.source == file.id)
.filter_map(|e| self.node(&e.target))
.filter(|n| n.kind == NodeKind::File) .filter(|n| n.kind == NodeKind::File)
.filter_map(|n| n.path.clone()) .filter_map(|n| n.path.clone())
.collect() .collect();
})
.unwrap_or_default();
let paths = Rc::new(paths); let paths = Rc::new(paths);
self.imported_paths self.imported_paths
.insert(path.to_owned(), Rc::clone(&paths)); .insert(path.to_owned(), Rc::clone(&paths));
paths 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 /// 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. /// a call position anywhere in the language, or C++ virtual via a prototype.
fn annotate_indirect_uses(&mut self) { fn annotate_indirect_uses(&mut self) {
@ -1408,8 +1474,10 @@ impl ScanState {
if let Some(id) = self.resolve_qualified_symbol(relationship) { if let Some(id) = self.resolve_qualified_symbol(relationship) {
return (id, None, None); return (id, None, None);
} }
let imported = if language == "python" || language == "dart" { let imported = if language == "python" {
self.imported_paths(&relationship.path) self.imported_paths(&relationship.path)
} else if language == "dart" {
Rc::new(self.dart_visible_libraries(&relationship.path, name))
} else { } else {
Rc::default() Rc::default()
}; };
@ -1953,8 +2021,17 @@ impl ScanState {
}; };
candidates candidates
.iter() .iter()
.filter(|(_, _, impl_path)| { .filter(|(_, _, impl_path, impl_type_path)| {
self.rust_type_file(ty, impl_path).as_deref() == Some(meant.as_str()) // `impl crate::a::Foo` says which Foo itself; otherwise ask what
// the impl's own file means by the leaf name.
let implemented = match impl_type_path {
Some(qualified) => self
.resolve_import(qualified, "rust", impl_path)
.and_then(|id| self.node(&id))
.and_then(|node| node.path.clone()),
None => self.rust_type_file(ty, impl_path),
};
implemented.as_deref() == Some(meant.as_str())
}) })
.collect() .collect()
} else { } else {
@ -1963,7 +2040,7 @@ impl ScanState {
let inherent: Vec<_> = candidates let inherent: Vec<_> = candidates
.iter() .iter()
.copied() .copied()
.filter(|(_, t, _)| t.is_none()) .filter(|(_, t, _, _)| t.is_none())
.collect(); .collect();
let chosen: Vec<_> = if inherent.is_empty() { let chosen: Vec<_> = if inherent.is_empty() {
candidates candidates
@ -1971,7 +2048,7 @@ impl ScanState {
inherent inherent
}; };
match chosen.as_slice() { match chosen.as_slice() {
[(id, _, _)] => Ok(id.clone()), [(id, _, _, _)] => Ok(id.clone()),
[] if self.rust.types.contains(ty) => Err("member-not-found"), [] if self.rust.types.contains(ty) => Err("member-not-found"),
[] => Err("receiver-type-external"), [] => Err("receiver-type-external"),
_ => Err("ambiguous"), _ => Err("ambiguous"),
@ -2199,6 +2276,55 @@ impl ScanState {
(candidates.len() == 1).then(|| candidates[0].clone()) (candidates.len() == 1).then(|| candidates[0].clone())
} }
/// A Python module name as a repository file. Leading dots are relative to the
/// importing package; a dotted name maps to `a/b.py` or `a/b/__init__.py` from the
/// repository root or `src/`. Only a single-component name (`import foo`) may
/// fall back to a unique module basename elsewhere; a dotted name never does,
/// since `x.common` is not just any `common.py`.
fn resolve_python_import(&self, name: &str, directory: &str) -> Option<String> {
let level = name.chars().take_while(|ch| *ch == '.').count();
let module = &name[level..];
if module.is_empty() {
// `from . import x`: the module is in the import list, not the name.
return None;
}
let relative = module.replace('.', "/");
let bases: Vec<String> = if level > 0 {
let mut base = Path::new(directory).to_path_buf();
for _ in 1..level {
base = base.parent()?.to_path_buf();
}
vec![base.to_str()?.to_owned()]
} else {
vec![".".into(), "src".into()]
};
for base in &bases {
let base = if base.is_empty() { "." } else { base.as_str() };
for candidate in [
format!("{base}/{relative}.py"),
format!("{base}/{relative}/__init__.py"),
] {
if let Some(path) = normalize_import_path(&candidate)
&& let Some(node) = self.node(&stable_id("file", &path)).filter(|n| {
n.kind == NodeKind::File && n.language.as_deref() == Some("python")
})
{
return Some(node.id.clone());
}
}
}
if level == 0 && !module.contains('.') {
for key in import_keys(module, "python") {
if let Some(ids) = self.files_by_key.get(&key)
&& ids.len() == 1
{
return Some(ids[0].clone());
}
}
}
None
}
fn resolve_import(&self, name: &str, language: &str, source_path: &str) -> Option<String> { fn resolve_import(&self, name: &str, language: &str, source_path: &str) -> Option<String> {
let directory = Path::new(source_path).parent()?.to_str()?; let directory = Path::new(source_path).parent()?.to_str()?;
let directory = if directory.is_empty() { "." } else { directory }; let directory = if directory.is_empty() { "." } else { directory };
@ -2258,6 +2384,7 @@ impl ScanState {
} }
} }
"rust" => paths.extend(rust_import_paths(name, directory)?), "rust" => paths.extend(rust_import_paths(name, directory)?),
"python" => return self.resolve_python_import(name, directory),
_ => { _ => {
for key in import_keys(name, language) { for key in import_keys(name, language) {
if let Some(ids) = self.files_by_key.get(&key) if let Some(ids) = self.files_by_key.get(&key)
@ -2465,10 +2592,18 @@ struct Patterns {
impl Patterns { impl Patterns {
fn new(language: &str) -> Self { fn new(language: &str) -> Self {
let (class, function, import) = match language { let (class, function, import) = match language {
// Swift's own forms: `func name(`, type declarations with modifiers and
// attributes, and module imports. It used to run through the C patterns,
// which never recognized `func`.
"swift" => (
r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:public|private|internal|fileprivate|open|final|indirect)\s+)*(?:class|struct|enum|protocol|actor|extension)\s+(?P<name>[A-Za-z_]\w*)(?:\s*:\s*(?P<base>[A-Za-z_][\w.]*))?",
r"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:(?:public|private|internal|fileprivate|open|static|class|override|mutating|nonmutating|final|required|convenience|dynamic|nonisolated)\s+)*func\s+(?P<name>[A-Za-z_]\w*)",
r"^\s*(?:@\w+\s+)?import\s+(?:(?:typealias|struct|class|enum|protocol|let|var|func)\s+)?(?P<name>[A-Za-z_][\w.]*)",
),
"python" => ( "python" => (
r"^\s*class\s+(?P<name>[A-Za-z_]\w*)(?:\((?P<base>[A-Za-z_][\w.]*)\))?", r"^\s*class\s+(?P<name>[A-Za-z_]\w*)(?:\((?P<base>[A-Za-z_][\w.]*)\))?",
r"^\s*(?:async\s+)?def\s+(?P<name>[A-Za-z_]\w*)\s*\(", r"^\s*(?:async\s+)?def\s+(?P<name>[A-Za-z_]\w*)\s*\(",
r"^\s*(?:from\s+(?P<name>[A-Za-z_][\w.]*)\s+import|import\s+(?P<name2>[A-Za-z_][\w.]*))", r"^\s*(?:from\s+(?P<name>\.*[A-Za-z_][\w.]*|\.+)\s+import|import\s+(?P<name2>[A-Za-z_][\w.]*))",
), ),
_ => ( _ => (
r"^\s*(?:class|struct)\s+(?P<name>[A-Za-z_]\w*)(?:\s*:\s*(?:public\s+|protected\s+|private\s+)?(?P<base>[A-Za-z_:]\w*(?:::\w+)*))?", r"^\s*(?:class|struct)\s+(?P<name>[A-Za-z_]\w*)(?:\s*:\s*(?:public\s+|protected\s+|private\s+)?(?P<base>[A-Za-z_:]\w*(?:::\w+)*))?",
@ -2509,6 +2644,69 @@ fn strip_line_comment<'a>(line: &'a str, language: &str) -> &'a str {
} }
} }
/// `class func` / `class var` declare members, not a class named `func`.
fn is_swift_member_keyword(name: &str) -> bool {
matches!(
name,
"func" | "var" | "let" | "subscript" | "init" | "deinit" | "case" | "typealias"
)
}
/// Mask `/* ... */` comments (nested, as Swift allows) with spaces, carrying the
/// open-comment depth across lines. Quoted strings are left intact, so a `/*`
/// inside a string (`"http://x/*y"`) does not open a comment.
fn mask_block_comments(line: &str, depth: &mut usize) -> String {
let chars: Vec<char> = line.chars().collect();
let mut result = String::with_capacity(line.len());
let mut quote = None;
let mut escaped = false;
let mut index = 0;
while index < chars.len() {
let ch = chars[index];
let next = chars.get(index + 1).copied();
if *depth > 0 {
if ch == '/' && next == Some('*') {
*depth += 1;
result.push_str(" ");
index += 2;
} else if ch == '*' && next == Some('/') {
*depth -= 1;
result.push_str(" ");
index += 2;
} else {
result.push(' ');
index += 1;
}
} else if let Some(active) = quote {
result.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active {
quote = None;
}
index += 1;
} else if ch == '"' && chars[index + 1..].contains(&'"') {
quote = Some(ch);
result.push(ch);
index += 1;
} else if ch == '/' && next == Some('/') {
// The rest is a line comment; strip_line_comment removes it.
result.extend(&chars[index..]);
break;
} else if ch == '/' && next == Some('*') {
*depth = 1;
result.push_str(" ");
index += 2;
} else {
result.push(ch);
index += 1;
}
}
result
}
fn brace_delta(line: &str) -> i32 { fn brace_delta(line: &str) -> i32 {
line.chars().fold(0, |depth, ch| match ch { line.chars().fold(0, |depth, ch| match ch {
'{' => depth + 1, '{' => depth + 1,

View file

@ -23,6 +23,9 @@ pub(crate) struct Declaration {
pub impl_type: Option<String>, pub impl_type: Option<String>,
/// Rust: trait of the enclosing `impl Trait for T` or `trait` block. /// Rust: trait of the enclosing `impl Trait for T` or `trait` block.
pub impl_trait: Option<String>, pub impl_trait: Option<String>,
/// Rust: the enclosing impl's type as written when it is a path
/// (`impl crate::a::Foo`), generic arguments removed; `None` for a bare name.
pub impl_type_path: Option<String>,
/// Rust: declared return type, whitespace removed. /// Rust: declared return type, whitespace removed.
pub return_type: Option<String>, pub return_type: Option<String>,
/// C++ `virtual` or `override`/`final` on this definition. /// C++ `virtual` or `override`/`final` on this definition.
@ -35,6 +38,25 @@ pub(crate) struct Declaration {
pub preprocessor_alternative: bool, 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(crate) struct ImportBinding {
pub local: String, pub local: String,
pub exported: String, pub exported: String,
@ -104,6 +126,8 @@ pub(crate) struct Document {
pub diagnostics: Vec<String>, pub diagnostics: Vec<String>,
pub backend: String, pub backend: String,
pub bindings: Vec<ImportBinding>, 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. /// C++ qualified names declared (prototype or definition) by this file.
pub declared: Vec<String>, pub declared: Vec<String>,
/// C/C++ preprocessor macro names defined by this file. /// C/C++ preprocessor macro names defined by this file.
@ -218,6 +242,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
diagnostics: Vec::new(), diagnostics: Vec::new(),
backend: format!("tree-sitter-{version}:brick-source-v1"), backend: format!("tree-sitter-{version}:brick-source-v1"),
bindings: Vec::new(), bindings: Vec::new(),
dart_directives: Vec::new(),
declared: Vec::new(), declared: Vec::new(),
macros: Vec::new(), macros: Vec::new(),
parse_incomplete: false, parse_incomplete: false,
@ -378,10 +403,10 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
{ {
doc.declared.push(qualified.clone()); doc.declared.push(qualified.clone());
} }
let (impl_type, impl_trait) = if language == "rust" { let (impl_type, impl_trait, impl_type_path) = if language == "rust" {
rust_impl_context(node, source) rust_impl_context(node, source)
} else { } else {
(None, None) (None, None, None)
}; };
let return_type = (language == "rust") let return_type = (language == "rust")
.then(|| node.child_by_field_name("return_type")) .then(|| node.child_by_field_name("return_type"))
@ -409,6 +434,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
internal, internal,
impl_type, impl_type,
impl_trait, impl_trait,
impl_type_path,
return_type, return_type,
}); });
} }
@ -665,6 +691,10 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
{ {
doc.bindings.extend(import_bindings(node, &target, source)); doc.bindings.extend(import_bindings(node, &target, source));
} }
if language == "dart" {
doc.dart_directives
.push(dart_directive(node, &target, source));
}
doc.relationships.push(Relationship { doc.relationships.push(Relationship {
owner: None, owner: None,
target, target,
@ -1094,6 +1124,35 @@ fn parameter_shadows<'tree>(
false 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> { fn import_bindings(node: Node<'_>, module: &str, source: &str) -> Vec<ImportBinding> {
let mut result = Vec::new(); let mut result = Vec::new();
// Type-only imports must not authorize runtime call resolution. // Type-only imports must not authorize runtime call resolution.
@ -1644,16 +1703,20 @@ fn cpp_first_template_type(node: Node<'_>, source: &str) -> Option<String> {
} }
/// Rust `impl T` / `impl Trait for T` / `trait Trait` enclosing a declaration. /// Rust `impl T` / `impl Trait for T` / `trait Trait` enclosing a declaration.
fn rust_impl_context(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) { type RustImplContext = (Option<String>, Option<String>, Option<String>);
/// (impl type leaf, implemented or declared trait, impl type path) around `node`.
fn rust_impl_context(node: Node<'_>, source: &str) -> RustImplContext {
let mut parent = node.parent(); let mut parent = node.parent();
while let Some(n) = parent { while let Some(n) = parent {
match n.kind() { match n.kind() {
"impl_item" => { "impl_item" => {
let ty = n.child_by_field_name("type");
return ( return (
n.child_by_field_name("type") ty.and_then(|ty| rust_type_name(ty, source)),
.and_then(|ty| rust_type_name(ty, source)),
n.child_by_field_name("trait") n.child_by_field_name("trait")
.and_then(|ty| rust_type_name(ty, source)), .and_then(|ty| rust_type_name(ty, source)),
ty.and_then(|ty| rust_type_path(ty, source)),
); );
} }
"trait_item" => { "trait_item" => {
@ -1661,13 +1724,30 @@ fn rust_impl_context(node: Node<'_>, source: &str) -> (Option<String>, Option<St
None, None,
n.child_by_field_name("name") n.child_by_field_name("name")
.map(|name| text(name, source).to_owned()), .map(|name| text(name, source).to_owned()),
None,
); );
} }
"function_item" | "closure_expression" => return (None, None), "function_item" | "closure_expression" => return (None, None, None),
_ => parent = n.parent(), _ => parent = n.parent(),
} }
} }
(None, None) (None, None, None)
}
/// A type written as a path (`crate::a::Foo`), references and generic arguments
/// removed; `None` for a bare `Foo`.
fn rust_type_path(ty: Node<'_>, source: &str) -> Option<String> {
match ty.kind() {
"scoped_type_identifier" => Some(format!(
"{}::{}",
text(ty.child_by_field_name("path")?, source),
text(ty.child_by_field_name("name")?, source)
)),
"generic_type" | "reference_type" => {
rust_type_path(ty.child_by_field_name("type")?, source)
}
_ => None,
}
} }
/// Leaf type name with references and generic arguments removed. /// Leaf type name with references and generic arguments removed.

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"]
);
}

View file

@ -306,6 +306,35 @@ fn check_recomputes_metrics_instead_of_trusting_the_file() {
assert!(text.contains("stored metrics"), "{text}"); assert!(text.contains("stored metrics"), "{text}");
} }
#[test]
fn a_model_from_an_older_build_passes_check_on_recomputed_metrics() {
// Any difference between stored and recomputed metrics failed the gate, so a
// model written before `metrics.resolution` existed always failed, even with
// every threshold met. Gate on the recomputed values; a mismatch is a warning.
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
fs::write(project.join("lib.rs"), "pub fn alpha() -> u32 { 1 }\n").unwrap();
let model = scan::scan(&project).unwrap();
let mut value: Value = serde_json::to_value(&model).unwrap();
value["metrics"]
.as_object_mut()
.unwrap()
.remove("resolution");
let older = directory.path().join("older.json");
fs::write(&older, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_brick"))
.args(["check", older.to_str().unwrap(), "--min-score", "0"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test] #[test]
fn a_scan_with_no_supported_source_is_reported_and_fails_check() { fn a_scan_with_no_supported_source_is_reported_and_fails_check() {
// A Go/Java-only directory built "1 nodes / 0 edges; score 100.0" with no // A Go/Java-only directory built "1 nodes / 0 edges; score 100.0" with no

80
tests/python_imports.rs Normal file
View file

@ -0,0 +1,80 @@
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()
}
/// (caller name, target path) for every resolved call.
fn resolved_calls(model: &Building) -> Vec<(String, String)> {
model
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Calls)
.filter_map(|edge| {
let source = model.nodes.iter().find(|n| n.id == edge.source)?;
let target = model.nodes.iter().find(|n| n.id == edge.target)?;
Some((source.name.clone(), target.path.clone()?))
})
.collect()
}
#[test]
fn relative_package_and_qualified_python_imports_resolve_to_the_right_file() {
// Python calls resolve only through import edges, and the lexical importer
// produced none for relative imports (the regex needed a leading letter),
// for packages (`from pkg import f` looked up the stem "pkg", not
// pkg/__init__.py), or when two files shared a stem.
let model = scan_files(&[
("pkg/__init__.py", "def from_package():\n return 1\n"),
("pkg/util.py", "def helper():\n return 1\n"),
(
"pkg/rel.py",
"from .util import helper\n\ndef use_relative():\n return helper()\n",
),
("x/common.py", "def only_here():\n return 1\n"),
("y/common.py", "def elsewhere():\n return 2\n"),
(
"main.py",
"from x.common import only_here\nfrom pkg import from_package\n\ndef run():\n only_here()\n return from_package()\n",
),
]);
let calls = resolved_calls(&model);
for (caller, target) in [
("use_relative", "pkg/util.py"),
("run", "x/common.py"),
("run", "pkg/__init__.py"),
] {
assert!(
calls
.iter()
.any(|(source, path)| source == caller && path == target),
"{caller} -> {target} missing: {calls:?}"
);
}
}
#[test]
fn an_unknown_dotted_python_module_does_not_fall_back_to_a_basename() {
// `from other.common import f` names a module this repository does not have;
// matching the stem "common" to y/common.py would invent a dependency.
let model = scan_files(&[
("y/common.py", "def f():\n return 2\n"),
(
"main.py",
"from other.common import f\n\ndef run():\n return f()\n",
),
]);
let calls = resolved_calls(&model);
assert!(
!calls.iter().any(|(_, path)| path == "y/common.py"),
"{calls:?}"
);
}

View file

@ -202,3 +202,39 @@ fn same_named_types_in_different_modules_resolve_through_use_bindings() {
let guess = function(&model, "src/other.rs", "guess"); let guess = function(&model, "src/other.rs", "guess");
assert_eq!(targets(guess), [(String::new(), "ambiguous".to_owned())]); assert_eq!(targets(guess), [(String::new(), "ambiguous".to_owned())]);
} }
#[test]
fn an_impl_on_a_qualified_path_in_another_file_keeps_its_methods() {
// With several files declaring `Foo`, an impl was kept only if its own file
// resolved `Foo`; `impl crate::a::Foo` in a third file resolved nothing and its
// methods were dropped, so the call read "member-not-found".
let model = scan_files(&[
(
"src/lib.rs",
"pub mod a;\npub mod c;\npub mod ext;\npub mod main_use;\n",
),
("src/a.rs", "pub struct Foo;\n"),
("src/c.rs", "pub struct Foo;\n"),
(
"src/ext.rs",
"impl crate::a::Foo {\n pub fn run(&self) {}\n}\n",
),
(
"src/main_use.rs",
"use crate::a::Foo;\npub fn f(x: Foo) {\n x.run();\n}\n",
),
]);
let caller = function(&model, "src/main_use.rs", "f");
let targets = calls(&model, caller, "source.resolution");
assert!(
targets.iter().any(|(_, name, _)| name == "run"),
"{targets:?}"
);
let run = function(&model, "src/ext.rs", "run");
assert!(
model
.edges
.iter()
.any(|e| e.kind == EdgeKind::Calls && e.source == caller.id && e.target == run.id)
);
}

98
tests/swift_preview.rs Normal file
View file

@ -0,0 +1,98 @@
use brick::model::{Building, EdgeKind, NodeKind};
use brick::scan;
use std::fs;
fn name_of<'a>(model: &'a Building, id: &str) -> &'a str {
model
.nodes
.iter()
.find(|node| node.id == id)
.map_or("", |node| node.name.as_str())
}
fn calls(model: &Building) -> Vec<(String, String, NodeKind)> {
model
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Calls)
.map(|edge| {
let source_kind = model
.nodes
.iter()
.find(|node| node.id == edge.source)
.map(|node| node.kind)
.unwrap();
(
name_of(model, &edge.source).to_owned(),
name_of(model, &edge.target).to_owned(),
source_kind,
)
})
.collect()
}
#[test]
fn swift_preview_declares_functions_and_ignores_comments() {
// Swift ran through the C-style lexical patterns: `func` declarations were
// never functions, each declaration line became a call from the file, calls
// inside (nested) block comments became edges, and real calls were attributed
// to the file instead of their function.
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("a.swift"),
r#"import Foundation
func realTarget() -> Int { return 1 }
func ghost() -> Int { return 2 }
final class Worker: NSObject {
let url = "http://example.com/*not-a-comment"
func caller() -> Int {
/* disabled:
ghost()
/* nested: ghost() */
still disabled: ghost()
*/
return realTarget()
}
}
"#,
)
.unwrap();
let model = scan::scan(dir.path()).unwrap();
let functions: Vec<&str> = model
.nodes
.iter()
.filter(|node| node.kind == NodeKind::Function)
.map(|node| node.name.as_str())
.collect();
for name in ["realTarget", "ghost", "caller"] {
assert!(
functions.contains(&name),
"{name} not a function: {functions:?}"
);
}
assert!(
model
.nodes
.iter()
.any(|node| node.kind == NodeKind::Class && node.name == "Worker")
);
let calls = calls(&model);
assert!(
calls.iter().any(|(source, target, kind)| source == "caller"
&& target == "realTarget"
&& *kind == NodeKind::Function),
"{calls:?}"
);
assert!(
!calls.iter().any(|(_, target, _)| target == "ghost"),
"a call inside a block comment became an edge: {calls:?}"
);
assert!(
!calls.iter().any(|(_, _, kind)| *kind == NodeKind::File),
"a declaration or statement was attributed to the file: {calls:?}"
);
}