From 48436f35b79f9219c7687830aacb5ff7e554c5e9 Mon Sep 17 00:00:00 2001 From: trvon Date: Tue, 22 Sep 2026 15:11:04 -0600 Subject: [PATCH 1/2] feat: resolve C/C++ and Rust calls across files and typed receivers C++ calls now resolve through the include closure: header prototypes pair with definitions in other translation units, lookup walks class (including in-repository bases), namespace, and `using` scopes outward, and static or anonymous-namespace definitions stay file-local. Member calls resolve only through explicitly declared receiver types (parameters, locals, fields, this, smart pointers, aliases); template parameters stay unknown. Calls hidden by the most vexing parse are recovered, casts are no longer calls, reference-returning and conversion-operator declarations are named correctly, and benign `= {}` default-argument recoveries no longer drop prototypes or flag files. Rust method calls resolve through a crate-wide impl index for self, typed parameters and lets, struct fields, Self-returning constructors, dyn traits, and Type::/Self:: associated calls, with foreign imports and generics never guessed. Calls inside expression macros are re-parsed. Every unresolved call records source.unresolved-reason, weaker resolutions record source.resolution/source.extraction, and metrics.resolution reports per-language call-site coverage. Declarations gain facts consumed by caller-status queries (qualified names, linkage, virtual, trait impls, attributes, name references, parse-incomplete). C++/Rust External targets include the reason in their identity; out-of-line C++ methods keep their IDs. Linear node/edge scans and per-node ancestor walks are replaced with indexes, cutting a YAMS scan from 575 s to 23 s. New labeled cpp_xtu and rust_methods corpora run three times in the quality gate. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/quality-gate.sh | 2 + src/metrics.rs | 68 +- src/model.rs | 15 + src/scan.rs | 952 ++++++++++- src/source.rs | 1450 ++++++++++++++++- tests/benchmarks/plans/cpp_xtu.json | 14 + tests/benchmarks/plans/rust_methods.json | 14 + tests/benchmarks/runner.py | 3 +- tests/cpp_xtu.rs | 328 ++++ .../fixtures/cpp_xtu/cpp/include/geo/shape.h | 21 + tests/fixtures/cpp_xtu/cpp/labels.json | 223 +++ tests/fixtures/cpp_xtu/cpp/src/buffer.h | 6 + tests/fixtures/cpp_xtu/cpp/src/legacy.c | 3 + tests/fixtures/cpp_xtu/cpp/src/main.cpp | 16 + tests/fixtures/cpp_xtu/cpp/src/other.cpp | 6 + tests/fixtures/cpp_xtu/cpp/src/overload.cpp | 4 + tests/fixtures/cpp_xtu/cpp/src/shape.cpp | 20 + tests/fixtures/cpp_xtu/cpp/src/util.cpp | 5 + tests/fixtures/cpp_xtu/cpp/src/util.h | 4 + tests/fixtures/cpp_xtu/cpp/tools/dup.cpp | 2 + tests/fixtures/rust_methods/rust/labels.json | 51 + tests/fixtures/rust_methods/rust/src/lib.rs | 19 + .../rust_methods/rust/src/negative.rs | 14 + tests/fixtures/rust_methods/rust/src/store.rs | 65 + tests/rust_methods.rs | 155 ++ 25 files changed, 3358 insertions(+), 102 deletions(-) create mode 100644 tests/benchmarks/plans/cpp_xtu.json create mode 100644 tests/benchmarks/plans/rust_methods.json create mode 100644 tests/cpp_xtu.rs create mode 100644 tests/fixtures/cpp_xtu/cpp/include/geo/shape.h create mode 100644 tests/fixtures/cpp_xtu/cpp/labels.json create mode 100644 tests/fixtures/cpp_xtu/cpp/src/buffer.h create mode 100644 tests/fixtures/cpp_xtu/cpp/src/legacy.c create mode 100644 tests/fixtures/cpp_xtu/cpp/src/main.cpp create mode 100644 tests/fixtures/cpp_xtu/cpp/src/other.cpp create mode 100644 tests/fixtures/cpp_xtu/cpp/src/overload.cpp create mode 100644 tests/fixtures/cpp_xtu/cpp/src/shape.cpp create mode 100644 tests/fixtures/cpp_xtu/cpp/src/util.cpp create mode 100644 tests/fixtures/cpp_xtu/cpp/src/util.h create mode 100644 tests/fixtures/cpp_xtu/cpp/tools/dup.cpp create mode 100644 tests/fixtures/rust_methods/rust/labels.json create mode 100644 tests/fixtures/rust_methods/rust/src/lib.rs create mode 100644 tests/fixtures/rust_methods/rust/src/negative.rs create mode 100644 tests/fixtures/rust_methods/rust/src/store.rs create mode 100644 tests/rust_methods.rs diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh index 2344eee..8b272f3 100755 --- a/scripts/quality-gate.sh +++ b/scripts/quality-gate.sh @@ -11,6 +11,8 @@ python3 -m unittest discover -s tests/benchmarks -p 'test_*.py' python3 tests/benchmarks/runner.py self-test python3 tests/benchmarks/runner.py run source_languages python3 tests/benchmarks/runner.py run rust_imports +python3 tests/benchmarks/runner.py run cpp_xtu +python3 tests/benchmarks/runner.py run rust_methods python3 tests/benchmarks/diff_runner.py self-test python3 tests/benchmarks/policy_runner.py self-test python3 tests/benchmarks/mcp_runner.py self-test diff --git a/src/metrics.rs b/src/metrics.rs index 08f0d01..7c33f86 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,8 +1,9 @@ use crate::model::{ - Building, Diagnostic, Edge, EdgeKind, Node, NodeKind, P4Metrics, StructuralHealth, + Building, Diagnostic, Edge, EdgeKind, EvidenceLane, Node, NodeKind, P4Metrics, + ResolutionMetrics, StructuralHealth, }; use serde_json::Value; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; const P4_DIAGNOSTIC_PREFIX: &str = "P4 structural observation (not verifier proof):"; @@ -87,6 +88,7 @@ pub fn calculate(building: &mut Building) { building.metrics.max_fan_in = max_fan_in; building.metrics.max_fan_out = max_fan_out; building.metrics.p4 = p4_metrics; + building.metrics.resolution = calculate_resolution(building); building.metrics.structural_health = StructuralHealth { score: round(score), experimental: true, @@ -104,6 +106,68 @@ pub fn calculate(building: &mut Building) { building.diagnostics.extend(p4_diagnostics); } +/// Counts syntactic call sites (heuristic evidence entries), not deduplicated edges. +fn calculate_resolution(building: &Building) -> BTreeMap { + let nodes: HashMap<&str, &Node> = building + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect(); + let mut result: BTreeMap = BTreeMap::new(); + let mut called = HashSet::new(); + for edge in building + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::Calls) + { + let (Some(source), Some(target)) = ( + nodes.get(edge.source.as_str()), + nodes.get(edge.target.as_str()), + ) else { + continue; + }; + let Some(language) = source.language.as_deref() else { + continue; + }; + let sites = edge + .evidence + .iter() + .filter(|evidence| evidence.lane == EvidenceLane::Heuristic) + .count(); + let metrics = result.entry(language.into()).or_default(); + metrics.call_site_count += sites; + if target.kind == NodeKind::External { + let reason = edge + .attributes + .get("source.unresolved-reason") + .and_then(Value::as_str) + .unwrap_or("unclassified"); + *metrics + .unresolved_call_sites_by_reason + .entry(reason.into()) + .or_default() += sites; + } else { + metrics.resolved_call_site_count += sites; + called.insert(target.id.as_str()); + } + } + for node in building + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Function) + { + let Some(language) = node.language.as_deref() else { + continue; + }; + let metrics = result.entry(language.into()).or_default(); + metrics.function_count += 1; + if !called.contains(node.id.as_str()) { + metrics.functions_without_resolved_callers += 1; + } + } + result +} + fn calculate_p4(building: &Building) -> (P4Metrics, Vec) { let nodes: HashMap<&str, &Node> = building .nodes diff --git a/src/model.rs b/src/model.rs index 8b0afb7..d227996 100644 --- a/src/model.rs +++ b/src/model.rs @@ -436,9 +436,24 @@ pub struct Metrics { pub max_fan_in: usize, pub max_fan_out: usize, pub p4: P4Metrics, + /// Per-language call-resolution coverage. Absent in models from older builds. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub resolution: BTreeMap, pub structural_health: StructuralHealth, } +/// How much of a language's call graph resolved to in-repository symbols. +/// A symbol without resolved callers is not evidence of dead code unless the +/// unresolved call sites that could reach it are accounted for. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolutionMetrics { + pub call_site_count: usize, + pub resolved_call_site_count: usize, + pub unresolved_call_sites_by_reason: BTreeMap, + pub function_count: usize, + pub functions_without_resolved_callers: usize, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct P4Metrics { pub parser_state_count: usize, diff --git a/src/scan.rs b/src/scan.rs index 1ae2d49..244eca9 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -11,6 +11,7 @@ use regex::Regex; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::rc::Rc; const SKIPPED_DIRECTORIES: [&str; 11] = [ ".git", @@ -85,7 +86,7 @@ pub fn scan_with_options(root: &Path, options: &ScanOptions) -> Result .unwrap_or("repository"); let root_id = stable_id("repository", "."); let mut state = ScanState::new(root.clone(), root_id.clone(), &normalized_options); - state.nodes.push(Node { + state.push_node(Node { id: root_id, kind: NodeKind::Repository, name: root_name.into(), @@ -98,6 +99,7 @@ pub fn scan_with_options(root: &Path, options: &ScanOptions) -> Result }); state.walk(&root)?; state.resolve_relationships(); + state.annotate_indirect_uses(); let mut building = Building { schema_version: SCHEMA_VERSION, @@ -127,6 +129,67 @@ struct PendingRelationship { confidence: f64, backend: String, attributes: BTreeMap, + cpp: Option, + rust: Option, +} + +/// Rust method-call facts for one call site; see `resolve_rust_member`. +#[derive(Debug)] +struct RustCall { + self_type: Option, + receiver: Option, +} + +/// A method node id and the trait it implements, if any. +type RustMethod = (String, Option); + +/// Crate-wide Rust impl/trait facts keyed by leaf type names. +#[derive(Default)] +struct RustIndex { + /// (type or trait, method) -> candidate methods. + methods: HashMap<(String, String), Vec>, + /// Struct, enum, and trait names declared in the repository. + types: HashSet, + /// (struct, field) -> declared type. + fields: HashMap<(String, String), crate::source::CppType>, + /// Method node id -> declared return type. + returns: HashMap, +} + +/// C++ lookup facts for one call site; see `resolve_cpp_call`. +#[derive(Debug)] +struct CppCall { + lookup_scope: String, + receiver_this: bool, + receiver: Option, +} + +/// Repository-wide C++ declaration facts used for include-visible resolution. +#[derive(Default)] +struct CppIndex { + /// Qualified name -> function definitions (node id, path, internal linkage). + definitions: HashMap>, + /// Qualified name -> files declaring it by prototype or definition. + declared_in: HashMap>, + /// Macro name -> files defining it. + macros: HashMap>, + /// Qualified names of repository classes/structs. + classes: HashSet, + /// (class, data member) -> declared type. + fields: HashMap<(String, String), crate::source::CppType>, + /// Class -> base class names as written. + bases: HashMap>, + /// Qualified names declared `virtual`/`override` anywhere. + virtuals: HashSet, + /// File -> `using namespace` directives. + using_namespaces: HashMap>, + /// File -> `using N::name;` (leaf, qualified target). + using_names: HashMap>, + /// Qualified alias name -> aliased type. + aliases: HashMap, + /// File -> directly included repository files. + includes: HashMap>, + closures: HashMap>>, } struct SymbolSpec<'a> { @@ -139,6 +202,8 @@ struct SymbolSpec<'a> { line: usize, backend: &'a str, attributes: BTreeMap, + /// Name hashed into the stable ID when it differs from the display name. + identity_name: Option<&'a str>, } struct P4TargetRequest<'a> { @@ -154,7 +219,8 @@ struct ScanState { root_id: String, nodes: Vec, edges: Vec, - edge_keys: HashSet<(String, String, EdgeKind)>, + edge_index: HashMap<(String, String, EdgeKind), usize>, + node_index: HashMap, symbol_ordinals: HashMap<(String, String, String, String), usize>, symbols_by_name: HashMap>, files_by_key: HashMap>, @@ -163,6 +229,10 @@ struct ScanState { excluded_directories: HashSet, p4_frontend: P4Frontend, source_bindings: HashMap>, + cpp: CppIndex, + rust: RustIndex, + /// Language -> identifiers used outside call/declaration-name positions. + references: HashMap>, } impl ScanState { @@ -172,7 +242,8 @@ impl ScanState { root_id, nodes: Vec::new(), edges: Vec::new(), - edge_keys: HashSet::new(), + edge_index: HashMap::new(), + node_index: HashMap::new(), symbol_ordinals: HashMap::new(), symbols_by_name: HashMap::new(), files_by_key: HashMap::new(), @@ -181,6 +252,9 @@ impl ScanState { p4_frontend: options.p4_frontend, excluded_directories: options.excluded_directories.iter().cloned().collect(), source_bindings: HashMap::new(), + cpp: CppIndex::default(), + rust: RustIndex::default(), + references: HashMap::new(), } } @@ -230,7 +304,7 @@ impl ScanState { .map(|value| stable_id("directory", &value)) .unwrap_or_else(|| self.root_id.clone()); let id = stable_id("directory", &relative); - self.nodes.push(Node { + self.push_node(Node { id: id.clone(), kind: NodeKind::Directory, name: path @@ -288,7 +362,7 @@ impl ScanState { .lines() .filter(|line| !line.trim().is_empty()) .count(); - self.nodes.push(Node { + self.push_node(Node { id: file_id.clone(), kind: NodeKind::File, name: path @@ -364,6 +438,7 @@ impl ScanState { line: line_number, backend: "brick-lexical", attributes: BTreeMap::new(), + identity_name: None, }); if let Some(base) = captures.name("base") { self.pending.push(PendingRelationship { @@ -377,6 +452,8 @@ impl ScanState { confidence: 0.8, backend: "brick-lexical".into(), attributes: BTreeMap::new(), + cpp: None, + rust: None, }); } scopes.push(Scope { @@ -406,6 +483,7 @@ impl ScanState { line: line_number, backend: "brick-lexical", attributes: BTreeMap::new(), + identity_name: None, }); scopes.push(Scope { id, @@ -434,6 +512,8 @@ impl ScanState { confidence: 0.85, backend: "brick-lexical".into(), attributes: BTreeMap::new(), + cpp: None, + rust: None, }); } } @@ -457,6 +537,8 @@ impl ScanState { confidence: 0.65, backend: "brick-lexical".into(), attributes: BTreeMap::new(), + cpp: None, + rust: None, }); } } @@ -514,6 +596,35 @@ impl ScanState { if !declaration.scope.is_empty() { attributes.insert("source.scope".into(), serde_json::json!(declaration.scope)); } + if !declaration.qualifier.is_empty() { + attributes.insert( + "source.qualifier".into(), + serde_json::json!(declaration.qualifier), + ); + } + if let Some(name) = &declaration.qualified_name { + attributes.insert("source.qualified-name".into(), serde_json::json!(name)); + } + if declaration.internal { + attributes.insert("source.linkage".into(), serde_json::json!("internal")); + } + if declaration.is_virtual { + attributes.insert("source.virtual".into(), serde_json::json!(true)); + } + if document.parse_incomplete && declaration.kind == NodeKind::Function { + attributes.insert("source.parse-incomplete".into(), serde_json::json!(true)); + } + if !declaration.attributes.is_empty() { + attributes.insert( + "source.attributes".into(), + serde_json::json!(declaration.attributes), + ); + } + if declaration.impl_type.is_some() + && let Some(implemented) = &declaration.impl_trait + { + attributes.insert("source.implements".into(), serde_json::json!(implemented)); + } ids[index] = Some(self.add_symbol(SymbolSpec { owner_id: &owner, path, @@ -528,11 +639,111 @@ impl ScanState { line: declaration.line, backend: &document.backend, attributes, + identity_name: Some(&declaration.identity), })); progress = true; } assert!(progress, "source declaration owners must be acyclic"); } + for (class, name, ty) in document.fields { + if language == "rust" { + self.rust.fields.entry((class, name)).or_insert(ty); + } else { + self.cpp.fields.entry((class, name)).or_insert(ty); + } + } + if language == "rust" { + for (declaration, id) in document.declarations.iter().zip(&ids) { + let Some(id) = id else { continue }; + if declaration.kind == NodeKind::Class { + self.rust.types.insert(declaration.name.clone()); + continue; + } + let Some(owner) = declaration + .impl_type + .clone() + .or_else(|| declaration.impl_trait.clone()) + else { + continue; + }; + // Trait declarations carry no implementing trait; trait impls name it. + let implemented = declaration + .impl_type + .is_some() + .then(|| declaration.impl_trait.clone()) + .flatten(); + self.rust + .methods + .entry((owner, declaration.name.clone())) + .or_default() + .push((id.clone(), implemented)); + if let Some(returns) = &declaration.return_type { + self.rust.returns.insert(id.clone(), returns.clone()); + } + } + } + for relationship in document + .relationships + .iter() + .filter(|relationship| relationship.kind == EdgeKind::Inherits) + { + if let Some(class) = relationship + .owner + .and_then(|owner| document.declarations[owner].qualified_name.clone()) + { + self.cpp + .bases + .entry(class) + .or_default() + .push(relationship.target.clone()); + } + } + for (declaration, id) in document.declarations.iter().zip(&ids) { + if declaration.kind == NodeKind::Class + && let Some(name) = &declaration.qualified_name + { + self.cpp.classes.insert(name.clone()); + } + if declaration.kind == NodeKind::Function + && let (Some(name), Some(id)) = (&declaration.qualified_name, id) + { + self.cpp.definitions.entry(name.clone()).or_default().push(( + id.clone(), + path.into(), + declaration.internal, + )); + } + } + for name in document.declared { + let files = self.cpp.declared_in.entry(name).or_default(); + if files.last().map(String::as_str) != Some(path) { + files.push(path.into()); + } + } + self.cpp.virtuals.extend(document.virtuals); + if !document.using_namespaces.is_empty() { + self.cpp + .using_namespaces + .insert(path.into(), document.using_namespaces); + } + if !document.using_names.is_empty() { + self.cpp + .using_names + .insert(path.into(), document.using_names); + } + for (alias, ty) in document.aliases { + self.cpp.aliases.entry(alias).or_insert(ty); + } + self.references + .entry(language.into()) + .or_default() + .extend(document.references); + for name in document.macros { + let files = self.cpp.macros.entry(name).or_default(); + if files.last().map(String::as_str) != Some(path) { + files.push(path.into()); + } + } self.source_bindings.insert(path.into(), document.bindings); for relationship in document.relationships { let source = relationship @@ -549,6 +760,9 @@ impl ScanState { if !relationship.scope.is_empty() { attributes.insert("source.scope".into(), serde_json::json!(relationship.scope)); } + if let Some(extraction) = relationship.extraction { + attributes.insert("source.extraction".into(), serde_json::json!(extraction)); + } self.pending.push(PendingRelationship { source, target_name: relationship.target, @@ -564,6 +778,17 @@ impl ScanState { }, backend: format!("{}:heuristic-resolver-v2", document.backend), attributes, + rust: (language == "rust" && relationship.kind == EdgeKind::Calls).then(|| { + RustCall { + self_type: relationship.self_type, + receiver: relationship.receiver.clone(), + } + }), + cpp: relationship.lookup_scope.map(|lookup_scope| CppCall { + lookup_scope, + receiver_this: relationship.receiver_this, + receiver: relationship.receiver, + }), }); } } @@ -599,6 +824,7 @@ impl ScanState { line: declaration.line, backend: "brick-p4-lexical", attributes, + identity_name: None, }); if matches!( declaration.kind, @@ -630,6 +856,8 @@ impl ScanState { confidence: 0.9, backend: "brick-p4-lexical".into(), attributes: p4_edge_attributes("include"), + cpp: None, + rust: None, }); } @@ -710,19 +938,14 @@ impl ScanState { ) else { continue; }; - let Some(parent_id) = self - .nodes - .iter() - .find(|node| node.id == symbol_id) - .and_then(|node| node.parent.clone()) - else { + let Some(parent_id) = self.node(&symbol_id).and_then(|node| node.parent.clone()) else { continue; }; - if let Some(edge) = self.edges.iter_mut().find(|edge| { - edge.source == parent_id - && edge.target == symbol_id - && edge.kind == EdgeKind::Defines - }) { + if let Some(&index) = + self.edge_index + .get(&(parent_id, symbol_id.clone(), EdgeKind::Defines)) + { + let edge = &mut self.edges[index]; edge.add_evidence(evidence( EvidenceLane::Syntactic, p4_tree_sitter::SYNTAX_BACKEND, @@ -755,13 +978,13 @@ impl ScanState { table_id, fact.action, path ); let id = stable_id("external", &identity); - if !self.nodes.iter().any(|node| node.id == id) { + if !self.node_index.contains_key(&id) { let mut attributes = p4_node_attributes("unresolved"); attributes.insert( "p4.expected-kind".into(), serde_json::Value::String("action".into()), ); - self.nodes.push(Node { + self.push_node(Node { id: id.clone(), kind: NodeKind::External, name: fact.action.clone(), @@ -867,6 +1090,7 @@ impl ScanState { line: request.line, backend: "brick-p4-lexical", attributes: p4_node_attributes("terminal-state"), + identity_name: None, }); scoped_symbols.insert(key, id.clone()); return id; @@ -880,13 +1104,13 @@ impl ScanState { request.path ); let id = stable_id("external", &identity); - if !self.nodes.iter().any(|node| node.id == id) { + if !self.node_index.contains_key(&id) { let mut attributes = p4_node_attributes("unresolved"); attributes.insert( "p4.expected-kind".into(), serde_json::Value::String(request.target_kind.as_str().into()), ); - self.nodes.push(Node { + self.push_node(Node { id: id.clone(), kind: NodeKind::External, name: request.target.into(), @@ -902,6 +1126,7 @@ impl ScanState { } fn add_symbol(&mut self, spec: SymbolSpec<'_>) -> String { + let identity_name = spec.identity_name.unwrap_or(spec.name); let ordinal_key = ( spec.path.into(), match spec @@ -913,12 +1138,12 @@ impl ScanState { None => spec.owner_id.into(), }, spec.identity_kind.into(), - spec.name.into(), + identity_name.into(), ); let ordinal = self.symbol_ordinals.entry(ordinal_key).or_default(); let mut identity = format!( "{}:{}:{}:{}:{ordinal}", - spec.path, spec.owner_id, spec.identity_kind, spec.name + spec.path, spec.owner_id, spec.identity_kind, identity_name ); if let Some(scope) = spec .attributes @@ -929,7 +1154,7 @@ impl ScanState { } *ordinal += 1; let id = stable_id(spec.identity_kind, &identity); - self.nodes.push(Node { + self.push_node(Node { id: id.clone(), kind: spec.node_kind, name: spec.name.into(), @@ -960,8 +1185,32 @@ impl ScanState { fn resolve_relationships(&mut self) { let pending = std::mem::take(&mut self.pending); - for relationship in pending { - let target = self.resolve_target(&relationship); + // Imports resolve first: C++ call visibility follows the include graph. + let (imports, others): (Vec<_>, Vec<_>) = pending + .into_iter() + .partition(|relationship| relationship.kind == EdgeKind::Imports); + for relationship in imports.into_iter().chain(others) { + let (target, reason, method) = self.resolve_target(&relationship); + if relationship.kind == EdgeKind::Imports + && relationship.language == "cpp" + && reason.is_none() + && let Some(path) = self.node(&target).and_then(|node| node.path.clone()) + { + self.cpp + .includes + .entry(relationship.path.clone()) + .or_default() + .push(path); + } + let mut attributes = relationship.attributes; + if let Some(method) = method { + attributes.insert("source.resolution".into(), serde_json::json!(method)); + } + if let Some(reason) = reason { + attributes + .entry("source.unresolved-reason".into()) + .or_insert_with(|| serde_json::json!(reason)); + } self.add_edge_with_attributes( relationship.source, target, @@ -972,18 +1221,80 @@ impl ScanState { relationship.confidence, format!("{}:{}", relationship.path, relationship.line), ), - relationship.attributes, + attributes, ); } } - fn resolve_target(&mut self, relationship: &PendingRelationship) -> String { + /// 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) { + for node in &mut self.nodes { + if node.kind != NodeKind::Function { + continue; + } + let Some(language) = node.language.as_deref() else { + continue; + }; + if self + .references + .get(language) + .is_some_and(|names| names.contains(&node.name)) + { + node.attributes + .insert("source.referenced-by-name".into(), serde_json::json!(true)); + } + if node + .attributes + .get("source.qualified-name") + .and_then(serde_json::Value::as_str) + .is_some_and(|name| self.cpp.virtuals.contains(name)) + { + node.attributes + .insert("source.virtual".into(), serde_json::json!(true)); + } + } + } + + /// Returns the target id, why an External target did not resolve, and a + /// label for resolutions weaker than name lookup (e.g. receiver typing). + fn resolve_target( + &mut self, + relationship: &PendingRelationship, + ) -> (String, Option<&'static str>, Option<&'static str>) { let name = &relationship.target_name; let kind = relationship.kind; let language = &relationship.language; + let shadowed = relationship + .attributes + .contains_key("source.unresolved-reason"); + let mut reason = if relationship.member_call { + "member-receiver" + } else if name.contains("::") || name.contains('.') { + "qualifier-unknown" + } else { + "not-visible" + }; if kind == EdgeKind::Imports { if let Some(id) = self.resolve_import(name, language, &relationship.path) { - return id; + return (id, None, None); + } + reason = "not-in-repository"; + } else if kind == EdgeKind::Calls + && !shadowed + && let Some(call) = &relationship.cpp + { + match self.resolve_cpp_call(relationship, call) { + Ok((id, method)) => return (id, None, method), + Err(cpp_reason) => reason = cpp_reason, + } + } else if relationship.member_call + && !shadowed + && let Some(call) = &relationship.rust + { + match self.resolve_rust_member(name, call, &relationship.path) { + Ok((id, method)) => return (id, None, Some(method)), + Err(rust_reason) => reason = rust_reason, } } else if !relationship.member_call && !relationship @@ -991,14 +1302,14 @@ impl ScanState { .contains_key("source.unresolved-reason") { if let Some(id) = self.resolve_qualified_symbol(relationship) { - return id; + return (id, None, None); } let candidates: Vec<_> = self .symbols_by_name .get(name) .into_iter() .flatten() - .filter_map(|id| self.nodes.iter().find(|n| &n.id == id)) + .filter_map(|id| self.node(id)) .filter(|n| { n.language.as_deref() == Some(language) && (kind != EdgeKind::Inherits || n.kind == NodeKind::Class) @@ -1017,26 +1328,35 @@ impl ScanState { }) .collect(); if visible.len() == 1 { - return visible[0].id.clone(); + return (visible[0].id.clone(), None, None); } if visible.len() > 1 { + reason = "ambiguous"; break; } - owner = self - .nodes - .iter() - .find(|n| n.id == id) - .and_then(|n| n.parent.as_deref()); + owner = self.node(id).and_then(|n| n.parent.as_deref()); } if let Some(id) = self.resolve_imported_symbol(relationship) { - return id; + return (id, None, None); } } else if candidates.len() == 1 { - return candidates[0].id.clone(); + return (candidates[0].id.clone(), None, None); + } else if candidates.len() > 1 { + reason = "ambiguous"; + } + } + if !relationship.member_call + && !shadowed + && let Some(call) = &relationship.rust + && let Some((qualifier, method)) = name.rsplit_once("::") + { + match self.resolve_rust_associated(qualifier, method, call, &relationship.path) { + Ok(id) => return (id, None, Some("impl-type")), + Err(rust_reason) => reason = rust_reason, } } // Unresolved targets in different languages/files/scopes are not the same entity. - let identity = if crate::source::supports(language) { + let mut identity = if crate::source::supports(language) { format!( "{}:{language}:{}:{}:{}:{name}", edge_kind_name(kind), @@ -1051,9 +1371,15 @@ impl ScanState { } else { format!("{}:{name}", edge_kind_name(kind)) }; + // C++/Rust call sites that fail for different reasons (e.g. a foreign + // receiver versus an untyped one) stay distinct so no site's reason is + // lost in an edge merge. + if (relationship.cpp.is_some() || relationship.rust.is_some()) && !shadowed { + identity.push_str(&format!(":{reason}")); + } let id = stable_id("external", &identity); - if !self.nodes.iter().any(|node| node.id == id) { - self.nodes.push(Node { + if !self.node_index.contains_key(&id) { + self.push_node(Node { id: id.clone(), kind: NodeKind::External, name: name.into(), @@ -1065,7 +1391,428 @@ impl ScanState { attributes: BTreeMap::new(), }); } - id + (id, (!shadowed).then_some(reason), None) + } + + /// C++ name lookup over the include closure: walk the caller's class and + /// namespace scopes outward and stop at the first scope where the name is + /// declared by a visible file. Duplicate definitions resolve only when + /// exactly one is paired with a visible declaration through its own includes. + fn resolve_cpp_call( + &mut self, + relationship: &PendingRelationship, + call: &CppCall, + ) -> Result<(String, Option<&'static str>), &'static str> { + use crate::source::Receiver; + let closure = self.cpp_closure(&relationship.path); + let (absolute, name) = match relationship.target_name.strip_prefix("::") { + Some(name) => (true, name), + None => (false, relationship.target_name.as_str()), + }; + if relationship.member_call { + let is_class = self.cpp.classes.contains(&call.lookup_scope); + if call.receiver_this { + return match is_class { + true => self.resolve_cpp_member(&call.lookup_scope, name, &closure), + false => Err("receiver-type-unknown"), + } + .map(|id| (id, None)); + } + let class = match &call.receiver { + Some(Receiver::Declared(ty)) => self + .resolve_cpp_class(ty, &call.lookup_scope, &closure) + .ok_or("receiver-type-external")?, + Some(Receiver::Field { name: field, arrow }) if is_class => { + let ty = self + .cpp_field_type(&call.lookup_scope, field, &closure) + .ok_or("receiver-type-unknown")?; + if ty.accessed(*arrow) == crate::source::GENERIC_TYPE { + return Err("receiver-type-unknown"); + } + self.resolve_cpp_class(&ty.accessed(*arrow), &call.lookup_scope, &closure) + .ok_or("receiver-type-external")? + } + _ => return Err("receiver-type-unknown"), + }; + return self + .resolve_cpp_member(&class, name, &closure) + .map(|id| (id, Some("declared-receiver-type"))); + } + // Function-like macros expand before name lookup. + if !name.contains("::") + && self + .cpp + .macros + .get(name) + .is_some_and(|files| files.iter().any(|file| closure.contains(file))) + { + return Err("macro"); + } + let levels = if absolute { + vec![String::new()] + } else { + scope_levels(&call.lookup_scope) + }; + for level in levels { + // Class scopes include members inherited from in-repository bases. + if self.cpp.classes.contains(&level) { + match self.resolve_cpp_member(&level, name, &closure) { + Err("member-not-found") => {} + result => return result.map(|id| (id, None)), + } + continue; + } + if let Some(result) = self.cpp_lookup(&qualify(&level, name), &closure) { + return result.map(|id| (id, None)); + } + } + if !absolute { + for qualified in self.cpp_using_candidates(name, &call.lookup_scope, &closure) { + if let Some(result) = self.cpp_lookup(&qualified, &closure) { + return result.map(|id| (id, Some("using-directive"))); + } + } + } + Err("not-visible") + } + + /// Resolve one qualified function name. `None` means no visible file + /// declares it, so ordinary lookup continues in an enclosing scope. + /// Duplicate definitions resolve only when exactly one is paired with a + /// visible declaration through its own includes. + fn cpp_lookup( + &mut self, + qualified: &str, + closure: &HashSet, + ) -> Option> { + let declaring: Vec = self + .cpp + .declared_in + .get(qualified) + .into_iter() + .flatten() + .filter(|file| closure.contains(*file)) + .cloned() + .collect(); + if declaring.is_empty() { + return None; + } + let definitions: Vec<(String, String)> = self + .cpp + .definitions + .get(qualified) + .into_iter() + .flatten() + .filter(|(_, file, internal)| !internal || closure.contains(file)) + .map(|(id, file, _)| (id.clone(), file.clone())) + .collect(); + Some(match definitions.as_slice() { + [] => Err("declared-only"), + [(id, _)] => Ok(id.clone()), + _ => { + let paired: Vec<&String> = definitions + .iter() + .filter(|(_, file)| { + closure.contains(file) + || self + .cpp_closure(file) + .iter() + .any(|included| declaring.contains(included)) + }) + .map(|(id, _)| id) + .collect(); + match paired.as_slice() { + [id] => Ok((*id).clone()), + _ => Err("ambiguous"), + } + } + }) + } + + /// A visible repository class named `name` from `scope` outward. + fn resolve_cpp_class( + &self, + name: &str, + scope: &str, + closure: &HashSet, + ) -> Option { + let levels = match name.strip_prefix("::") { + Some(_) => vec![String::new()], + None => scope_levels(scope), + }; + let absolute = name.starts_with("::"); + let name = name.trim_start_matches("::"); + let visible = |class: &String| { + self.cpp.classes.contains(class) + && self + .cpp + .declared_in + .get(class) + .is_some_and(|files| files.iter().any(|file| closure.contains(file))) + }; + if let Some(class) = levels + .into_iter() + .map(|level| qualify(&level, name)) + .find(visible) + { + return Some(class); + } + let mut aliases = levels_for(name, scope); + if !absolute { + if let Some(class) = self + .cpp_using_candidates(name, scope, closure) + .into_iter() + .find(visible) + { + return Some(class); + } + aliases.extend(self.cpp_using_candidates(name, scope, closure)); + } + // A type alias names its target; smart-pointer aliases name the pointee. + aliases.into_iter().find_map(|alias| { + let ty = self.cpp.aliases.get(&alias)?; + let alias_scope = alias.rsplit_once("::").map_or("", |(scope, _)| scope); + [ty.pointee.as_deref(), Some(ty.name.as_str())] + .into_iter() + .flatten() + .flat_map(|target| levels_for(target, alias_scope)) + .find(visible) + }) + } + + /// Qualified names that `using namespace N;` / `using N::x;` directives in + /// the include closure make `name` refer to, innermost scope first. + fn cpp_using_candidates( + &self, + name: &str, + scope: &str, + closure: &HashSet, + ) -> Vec { + let mut candidates = Vec::new(); + let (head, rest) = match name.split_once("::") { + Some((head, rest)) => (head, Some(rest)), + None => (name, None), + }; + let mut files: Vec<&String> = closure.iter().collect(); + files.sort(); + for file in files { + for (leaf, target) in self.cpp.using_names.get(file).into_iter().flatten() { + if leaf == head { + candidates.push(match rest { + Some(rest) => qualify(target, rest), + None => target.clone(), + }); + } + } + for namespace in self.cpp.using_namespaces.get(file).into_iter().flatten() { + for level in scope_levels(scope) { + candidates.push(qualify(&qualify(&level, namespace), name)); + } + } + } + let mut seen = HashSet::new(); + candidates.retain(|candidate| seen.insert(candidate.clone())); + candidates + } + + /// Method lookup in a class, then breadth-first through in-repository bases. + fn resolve_cpp_member( + &mut self, + class: &str, + name: &str, + closure: &HashSet, + ) -> Result { + let mut queue = std::collections::VecDeque::from([class.to_owned()]); + let mut seen = HashSet::new(); + while let Some(class) = queue.pop_front() { + if !seen.insert(class.clone()) { + continue; + } + if let Some(result) = self.cpp_lookup(&qualify(&class, name), closure) { + return result; + } + for base in self.cpp.bases.get(&class).cloned().unwrap_or_default() { + if let Some(base) = self.resolve_cpp_class(&base, &class, closure) { + queue.push_back(base); + } + } + } + Err("member-not-found") + } + + fn cpp_field_type( + &self, + class: &str, + field: &str, + closure: &HashSet, + ) -> Option { + let mut queue = std::collections::VecDeque::from([class.to_owned()]); + let mut seen = HashSet::new(); + while let Some(class) = queue.pop_front() { + if !seen.insert(class.clone()) { + continue; + } + if let Some(ty) = self.cpp.fields.get(&(class.clone(), field.to_owned())) { + return Some(ty.clone()); + } + for base in self.cpp.bases.get(&class).into_iter().flatten() { + if let Some(base) = self.resolve_cpp_class(base, &class, closure) { + queue.push_back(base); + } + } + } + None + } + + /// `recv.method()` through the receiver's declared type. Inherent methods + /// win over trait methods, as in Rust method resolution; several trait + /// candidates stay ambiguous rather than picking one. + fn resolve_rust_member( + &self, + method: &str, + call: &RustCall, + path: &str, + ) -> Result<(String, &'static str), &'static str> { + use crate::source::Receiver; + let (ty, label) = match &call.receiver { + Some(Receiver::SelfValue) => ( + call.self_type.clone().ok_or("receiver-type-unknown")?, + "self-receiver", + ), + Some(Receiver::Declared(ty)) => (ty.clone(), "declared-receiver-type"), + Some(Receiver::Field { name, .. }) => { + let owner = call.self_type.as_ref().ok_or("receiver-type-unknown")?; + let field = self + .rust + .fields + .get(&(owner.clone(), name.clone())) + .ok_or("receiver-type-unknown")?; + (field.accessed(true), "declared-receiver-type") + } + Some(Receiver::Returned { ty, func, fallible }) => ( + self.rust_constructed_type(ty, func, *fallible, call) + .ok_or("receiver-type-unknown")?, + "declared-receiver-type", + ), + _ => return Err("receiver-type-unknown"), + }; + if self.rust_type_is_foreign(&ty, path) { + return Err("receiver-type-external"); + } + self.rust_method(&ty, method).map(|id| (id, label)) + } + + /// A type name the caller's file imports from outside the crate. + fn rust_type_is_foreign(&self, name: &str, path: &str) -> bool { + self.source_bindings + .get(path) + .into_iter() + .flatten() + .any(|binding| { + binding.local == name + && !matches!( + binding.module.split("::").next(), + Some("crate" | "self" | "super") + ) + }) + } + + /// `Type::func()` / `Self::func()` / `Trait::func()` by the impl index. + fn resolve_rust_associated( + &self, + qualifier: &str, + method: &str, + call: &RustCall, + path: &str, + ) -> Result { + // Other crates' paths (`std::fs::File::open`) never reach repository impls. + if qualifier.contains("::") + && !matches!( + qualifier.split("::").next(), + Some("crate" | "self" | "super") + ) + { + return Err("qualifier-unknown"); + } + let leaf = qualifier.rsplit("::").next().unwrap_or(qualifier); + if self.rust_type_is_foreign(leaf, path) { + return Err("qualifier-unknown"); + } + let ty = if leaf == "Self" { + call.self_type.clone().ok_or("qualifier-unknown")? + } else { + leaf.to_owned() + }; + if !self.rust.types.contains(&ty) { + return Err("qualifier-unknown"); + } + self.rust_method(&ty, method) + } + + fn rust_method(&self, ty: &str, method: &str) -> Result { + let candidates = self + .rust + .methods + .get(&(ty.to_owned(), method.to_owned())) + .map(Vec::as_slice) + .unwrap_or_default(); + let inherent: Vec<_> = candidates.iter().filter(|(_, t)| t.is_none()).collect(); + let chosen: Vec<_> = if inherent.is_empty() { + candidates.iter().collect() + } else { + inherent + }; + match chosen.as_slice() { + [(id, _)] => Ok(id.clone()), + [] if self.rust.types.contains(ty) => Err("member-not-found"), + [] => Err("receiver-type-external"), + _ => Err("ambiguous"), + } + } + + /// `Type::func(..)` yields `Type` when `func` is declared to return `Self` + /// or `Type` (behind `?`, also `Result<..>`/`Option<..>` of either). + fn rust_constructed_type( + &self, + ty: &str, + func: &str, + fallible: bool, + call: &RustCall, + ) -> Option { + let ty = if ty == "Self" { + call.self_type.clone()? + } else { + ty.to_owned() + }; + let id = self.rust_method(&ty, func).ok()?; + let returns = self.rust.returns.get(&id)?; + let returns_type = |value: &str| value == "Self" || value == ty; + let direct = returns_type(returns); + let wrapped = fallible + && ["Result<", "Option<", "anyhow::Result<", "io::Result<"] + .iter() + .filter_map(|prefix| returns.strip_prefix(prefix)) + .any(|rest| returns_type(rest.split([',', '>']).next().unwrap_or(""))); + (direct || wrapped).then_some(ty) + } + + /// The file plus every repository file it transitively includes. + fn cpp_closure(&mut self, path: &str) -> Rc> { + if let Some(closure) = self.cpp.closures.get(path) { + return closure.clone(); + } + let mut closure = HashSet::from([path.to_owned()]); + let mut queue = vec![path.to_owned()]; + while let Some(file) = queue.pop() { + for included in self.cpp.includes.get(&file).into_iter().flatten() { + if closure.insert(included.clone()) { + queue.push(included.clone()); + } + } + } + let closure = Rc::new(closure); + self.cpp.closures.insert(path.to_owned(), closure.clone()); + closure } fn resolve_imported_symbol(&self, relationship: &PendingRelationship) -> Option { @@ -1160,8 +1907,11 @@ impl ScanState { && let Some(file) = self.resolve_import(qualifier, "rust", &relationship.path) { candidates.extend( - self.nodes - .iter() + self.symbols_by_name + .get(name) + .into_iter() + .flatten() + .filter_map(|id| self.node(id)) .filter(|n| { n.name == name && n.parent.as_deref() == Some(&file) @@ -1172,21 +1922,28 @@ impl ScanState { ); } // Explicit local namespace/type qualifiers never fall back to a bare leaf. - for node in self.nodes.iter().filter(|n| { - n.name == name - && n.path.as_deref() == Some(&relationship.path) - && n.language.as_deref() == Some(&relationship.language) - }) { + for node in self + .symbols_by_name + .get(name) + .into_iter() + .flatten() + .filter_map(|id| self.node(id)) + .filter(|n| { + n.name == name + && n.path.as_deref() == Some(&relationship.path) + && n.language.as_deref() == Some(&relationship.language) + }) + { let scope = node .attributes .get("source.scope") .and_then(serde_json::Value::as_str) .unwrap_or(""); - let owner = node.parent.as_deref().and_then(|id| { - self.nodes - .iter() - .find(|n| n.id == id && n.kind == NodeKind::Class) - }); + let owner = node + .parent + .as_deref() + .and_then(|id| self.node(id)) + .filter(|n| n.kind == NodeKind::Class); let qualified = match owner { Some(owner) if scope.is_empty() => owner.name.clone(), Some(owner) => format!("{scope}::{}", owner.name), @@ -1263,9 +2020,8 @@ impl ScanState { } } for path in paths { - if let Some(node) = self.nodes.iter().find(|n| { + if let Some(node) = self.node(&stable_id("file", &path)).filter(|n| { n.kind == NodeKind::File - && n.path.as_deref() == Some(&path) && (n.language.as_deref() == Some(language) || language == "typescript" && n.language.as_deref() == Some("javascript")) }) { @@ -1276,15 +2032,21 @@ impl ScanState { && let Some((module, symbol)) = name.rsplit_once("::") { for path in rust_import_paths(module, directory)? { - if let Some(file) = self.nodes.iter().find(|n| { - n.kind == NodeKind::File - && n.language.as_deref() == Some("rust") - && n.path.as_deref() == Some(&path) - }) && self.nodes.iter().any(|node| { - node.parent.as_deref() == Some(&file.id) - && node.name == symbol - && node.attributes.contains_key("source.exports") - }) { + if let Some(file) = self + .node(&stable_id("file", &path)) + .filter(|n| n.kind == NodeKind::File && n.language.as_deref() == Some("rust")) + && self + .symbols_by_name + .get(symbol) + .into_iter() + .flatten() + .filter_map(|id| self.node(id)) + .any(|node| { + node.parent.as_deref() == Some(&file.id) + && node.name == symbol + && node.attributes.contains_key("source.exports") + }) + { return Some(file.id.clone()); } } @@ -1333,17 +2095,13 @@ impl ScanState { attributes: BTreeMap, ) { let key = (source.clone(), target.clone(), kind); - if !self.edge_keys.insert(key) { - if let Some(edge) = self - .edges - .iter_mut() - .find(|edge| edge.source == source && edge.target == target && edge.kind == kind) - { - edge.add_evidence(evidence); - merge_edge_attributes(&mut edge.attributes, attributes); - } + if let Some(&index) = self.edge_index.get(&key) { + let edge = &mut self.edges[index]; + edge.add_evidence(evidence); + merge_edge_attributes(&mut edge.attributes, attributes); return; } + self.edge_index.insert(key, self.edges.len()); let identity = format!("{source}:{}:{target}", edge_kind_name(kind)); self.edges.push(Edge { id: stable_id("edge", &identity), @@ -1364,10 +2122,48 @@ impl ScanState { } fn node_kind(&self, id: &str) -> Option { - self.nodes - .iter() - .find(|node| node.id == id) - .map(|node| node.kind) + self.node(id).map(|node| node.kind) + } + + fn node(&self, id: &str) -> Option<&Node> { + self.node_index.get(id).map(|&index| &self.nodes[index]) + } + + fn push_node(&mut self, node: Node) { + self.node_index.insert(node.id.clone(), self.nodes.len()); + self.nodes.push(node); + } +} + +/// Candidate qualified names for `name` looked up from `scope` outward. +fn levels_for(name: &str, scope: &str) -> Vec { + match name.strip_prefix("::") { + Some(name) => vec![name.to_owned()], + None => scope_levels(scope) + .into_iter() + .map(|level| qualify(&level, name)) + .collect(), + } +} + +/// `a::b` -> ["a::b", "a", ""]: C++ unqualified lookup from innermost scope outward. +fn scope_levels(scope: &str) -> Vec { + let segments: Vec<&str> = if scope.is_empty() { + Vec::new() + } else { + scope.split("::").collect() + }; + (0..=segments.len()) + .rev() + .map(|length| segments[..length].join("::")) + .collect() +} + +fn qualify(scope: &str, name: &str) -> String { + if scope.is_empty() { + name.to_owned() + } else { + format!("{scope}::{name}") } } diff --git a/src/source.rs b/src/source.rs index bf69dec..8228191 100644 --- a/src/source.rs +++ b/src/source.rs @@ -11,6 +11,25 @@ pub(crate) struct Declaration { pub scope: String, pub line: usize, pub exports: Vec, + /// Declarator text used for stable identity (C++ `A::f` keeps its historical ID). + pub identity: String, + /// Explicit out-of-line qualifier such as `A` in `void A::f() {}`. + pub qualifier: String, + /// C++ namespace/class/qualifier path plus name; `None` for other languages. + pub qualified_name: Option, + /// C++ `static` free function or anonymous-namespace member. + pub internal: bool, + /// Rust: type named by the enclosing `impl` block. + pub impl_type: Option, + /// Rust: trait of the enclosing `impl Trait for T` or `trait` block. + pub impl_trait: Option, + /// Rust: declared return type, whitespace removed. + pub return_type: Option, + /// C++ `virtual` or `override`/`final` on this definition. + pub is_virtual: bool, + /// Rust outer attributes that may register the item with a framework + /// (`test`, `tokio::main`, `tool`, ...), excluding lint/doc/cfg attributes. + pub attributes: Vec, } pub(crate) struct ImportBinding { @@ -28,6 +47,52 @@ pub(crate) struct Relationship { pub line: usize, pub scope: String, pub shadowed: bool, + /// C++ `this->f()`: a member call whose receiver is the enclosing class. + pub receiver_this: bool, + /// C++ name-lookup scope of the call site (enclosing class/namespace path). + pub lookup_scope: Option, + /// Member-call receiver evidence (C++ other than `this`; Rust including `self`). + pub receiver: Option, + /// Rust: `Self` type of the calling method (impl type, or trait for defaults). + pub self_type: Option, + /// How a call hidden from ordinary syntax was recovered, if it was + /// (`macro-reparse`, `vexing-parse`). + pub extraction: Option<&'static str>, +} + +/// A declared C++ type as written: `name` for `.` access, `pointee` for `->` +/// through a smart pointer (raw pointers use `name` for both). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CppType { + pub name: String, + pub pointee: Option, +} + +impl CppType { + pub(crate) fn accessed(&self, arrow: bool) -> String { + match (&self.pointee, arrow) { + (Some(pointee), true) => pointee.clone(), + _ => self.name.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Receiver { + /// Explicitly declared parameter/local type, already adjusted for `.`/`->`. + Declared(String), + /// Not a local: possibly a member of the enclosing class (`x_.f()`, `this->x_.f()`). + Field { name: String, arrow: bool }, + /// Rust `self`. + SelfValue, + /// Rust `Type::func(..)` result, typed only if `func` returns `Self`/`Type`. + Returned { + ty: String, + func: String, + fallible: bool, + }, + /// Receiver is an expression whose type Brick does not infer. + Unknown, } pub(crate) struct Document { @@ -36,6 +101,25 @@ pub(crate) struct Document { pub diagnostics: Vec, pub backend: String, pub bindings: Vec, + /// C++ qualified names declared (prototype or definition) by this file. + pub declared: Vec, + /// C/C++ preprocessor macro names defined by this file. + pub macros: Vec, + /// The file has real syntax errors: scopes and calls near them may be wrong. + pub parse_incomplete: bool, + /// C++ data members: (class qualified name, member name, declared type). + pub fields: Vec<(String, String, CppType)>, + /// C++ qualified names whose prototypes are `virtual`/`override`. + pub virtuals: Vec, + /// C++ `using namespace N;` directives (anywhere in the file). + pub using_namespaces: Vec, + /// C++ `using N::name;` declarations: (local leaf, qualified target). + pub using_names: Vec<(String, String)>, + /// C++ type aliases: (qualified alias name, aliased type). + pub aliases: Vec<(String, CppType)>, + /// C++/Rust identifiers used outside call and declaration-name positions + /// (callbacks, function pointers, macro tokens): possible indirect uses. + pub references: Vec, } pub(crate) fn supports(language: &str) -> bool { @@ -73,30 +157,93 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result = Vec::new(); let mut stack = vec![tree.root_node()]; while let Some(node) = stack.pop() { + if is_benign_missing(node) { + continue; + } if node.is_error() || node.is_missing() { + errors.push((node.start_byte(), node.end_byte())); + if track_references { + // Omitted syntax may still contain calls; keep its names as possible uses. + let mut tokens = vec![node]; + while let Some(token) = tokens.pop() { + if token.kind() == "identifier" { + reference_candidates.push(token); + } + let mut cursor = token.walk(); + tokens.extend(token.named_children(&mut cursor)); + } + } doc.diagnostics.push(format!( "source grammar error at line {}; omitted incomplete syntax", node.start_position().row + 1 )); continue; } + if track_references && node.kind() == "token_tree" { + // Macro tokens are opaque syntax; any identifier may be a use. + // Attribute arguments are keys; their string values may name functions + // (`#[serde(deserialize_with = "path::f")]`). + let attribute = has_ancestor(node, |n| n.kind() == "attribute_item"); + let mut tokens = vec![node]; + while let Some(token) = tokens.pop() { + if attribute && token.kind() == "string_content" { + let value = text(token, source); + let leaf = value.rsplit("::").next().unwrap_or(value); + if !leaf.is_empty() && leaf.chars().all(|c| c.is_alphanumeric() || c == '_') { + attribute_references.push(leaf.to_owned()); + } + } else if !attribute && token.kind() == "identifier" { + reference_candidates.push(token); + } + let mut cursor = token.walk(); + tokens.extend(token.named_children(&mut cursor)); + } + } + if track_references && node.kind() == "preproc_arg" { + // A macro body (`#define dlopen dlopen_impl`) names what it expands to. + attribute_references.extend( + text(node, source) + .split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|token| token.starts_with(|c: char| c.is_alphabetic() || c == '_')) + .map(str::to_owned), + ); + } if matches!( node.kind(), "comment" | "line_comment" | "block_comment" | "token_tree" | "preproc_arg" ) { continue; } + if track_references && node.kind() == "identifier" && is_reference(node) { + reference_candidates.push(node); + } nodes.push(node); if node.kind() == "preproc_include" { // A computed include argument is not an executable function call. @@ -106,6 +253,36 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result = node.named_children(&mut cursor).collect(); stack.extend(children.into_iter().rev()); } + // Anonymous MISSING tokens (`#endif`, `;`, `}`) are not named children; find + // them by descending only into subtrees that report an error. + let mut stack = vec![tree.root_node()]; + while let Some(node) = stack.pop() { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.is_missing() && !child.is_named() { + errors.push((child.start_byte(), child.end_byte())); + doc.diagnostics.push(format!( + "source grammar error at line {}; omitted incomplete syntax", + child.start_position().row + 1 + )); + } else if child.has_error() && !child.is_error() { + stack.push(child); + } + } + } + doc.parse_incomplete = !errors.is_empty(); + if doc.parse_incomplete { + doc.diagnostics.insert( + 0, + "source grammar reported incomplete syntax; only recoverable facts are retained".into(), + ); + } + // Like `Node::has_error`, but ignoring benign zero-width recoveries. + let has_error = |node: Node<'_>| { + errors.iter().any(|&(start, end)| { + start < node.end_byte() && end >= node.start_byte() && start >= node.start_byte() + }) + }; let mut declarations = HashMap::new(); let mut declaration_nodes = Vec::new(); for &node in &nodes { @@ -117,13 +294,50 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result ( + cpp.identity, + cpp.qualifier, + Some(cpp.qualified_name), + cpp.internal, + ), + None => (name.clone(), String::new(), None, false), + }; + if let Some(qualified) = &qualified_name { + doc.declared.push(qualified.clone()); + } + let (impl_type, impl_trait) = if language == "rust" { + rust_impl_context(node, source) + } else { + (None, None) + }; + let return_type = (language == "rust") + .then(|| node.child_by_field_name("return_type")) + .flatten() + .map(|ty| text(ty, source).split_whitespace().collect::()); + let is_virtual = language == "cpp" && cpp_is_virtual(node); + let attributes = if language == "rust" { + rust_outer_attributes(node, source) + } else { + Vec::new() + }; doc.declarations.push(Declaration { + is_virtual, + attributes, exports: exported_names(node, &name), name, kind, owner: None, scope: lexical_scope(node, source), line: node.start_position().row + 1, + identity, + qualifier, + qualified_name, + internal, + impl_type, + impl_trait, + return_type, }); } } @@ -172,9 +386,31 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result Result Result doc.declarations[index] + .qualified_name + .as_deref() + .and_then(|name| name.rsplit_once("::")) + .map(|(parent, _)| parent.to_owned()) + .unwrap_or_default(), + None => String::new(), + }), + receiver: None, + self_type: None, + extraction: Some("vexing-parse"), + }); + } + if matches!(node.kind(), "preproc_function_def" | "preproc_def") + && let Some(name) = node.child_by_field_name("name") + { + doc.macros.push(text(name, source).to_owned()); + } + } + let self_type = owner.and_then(|index| { + let declaration = &doc.declarations[index]; + declaration + .impl_type + .clone() + .or_else(|| declaration.impl_trait.clone()) + }); + if language == "rust" { + if node.kind() == "field_declaration" { + doc.fields.extend(rust_fields(node, source)); + } + if node.kind() == "macro_invocation" { + let context = MacroContext { + position: node, + owner, + scope: &scope, + self_type: &self_type, + source, + }; + if let Some(parser) = macro_parser.as_mut() { + let calls = + rust_macro_calls(&context, parser, &mut rust_locals, &mut doc.diagnostics); + doc.relationships.extend(calls); + } + continue; + } + } if node.kind() == "call_expression" - && !node.has_error() + && !has_error(node) && let Some(function) = node.child_by_field_name("function") && let Some((target, member)) = call_target(function, source) { - doc.relationships.push(Relationship { - shadowed: !member && parameter_shadows(node, &target, source), - owner, - target, - member, - kind: EdgeKind::Calls, - line: node.start_position().row + 1, - scope: scope.clone(), - }); + let cpp = language == "cpp"; + let target = if cpp { + strip_template_arguments(&target) + } else { + target + }; + // Casts parse as template calls but never name a callable declaration. + if !(cpp && is_cpp_cast(&target)) { + let receiver_this = cpp + && member + && function.kind() == "field_expression" + && function + .child_by_field_name("argument") + .is_some_and(|receiver| receiver.kind() == "this"); + let lookup_scope = cpp.then(|| match owner { + Some(index) => doc.declarations[index] + .qualified_name + .as_deref() + .and_then(|name| name.rsplit_once("::")) + .map(|(parent, _)| parent.to_owned()) + .unwrap_or_default(), + None => cpp_enclosing_path(node, source).join("::"), + }); + let receiver = if cpp { + (member && !receiver_this) + .then(|| cpp_receiver(node, function, source, &mut local_types)) + } else if language == "rust" && member { + function + .child_by_field_name("value") + .map(|value| rust_receiver(value, source, node, source, &mut rust_locals)) + } else { + None + }; + doc.relationships.push(Relationship { + shadowed: !member && parameter_shadows(node, &target, source), + owner, + target, + member, + kind: EdgeKind::Calls, + line: node.start_position().row + 1, + scope: scope.clone(), + receiver_this, + lookup_scope, + receiver, + self_type: self_type.clone(), + extraction: None, + }); + } } if let Some(target) = import_target(node, language, source) { if node.kind() == "import_statement" && matches!(language, "javascript" | "typescript") @@ -225,6 +593,11 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result Result> = HashMap::new(); + let mut references: std::collections::BTreeSet = + attribute_references.into_iter().collect(); + for candidate in reference_candidates { + let name = text(candidate, source); + let mut function = candidate.parent(); + while let Some(n) = function { + if matches!(n.kind(), "function_item" | "function_definition") { + break; + } + function = n.parent(); + } + let local = function.is_some_and(|function| { + bindings + .entry(function.id()) + .or_insert_with(|| local_bindings(function, source)) + .contains(name) + }); + if !local { + references.insert(name.to_owned()); + } + } + doc.references = references.into_iter().collect(); Ok(doc) } @@ -284,11 +686,10 @@ fn declaration(node: Node<'_>, language: &str, source: &str) -> Option<(String, return (!name.has_error()).then(|| (text(name, source).to_owned(), NodeKind::Class)); } let name = if language == "cpp" && node.kind() == "function_definition" { - let mut declarator = node.child_by_field_name("declarator")?; - while let Some(inner) = declarator.child_by_field_name("declarator") { - declarator = inner; - } - Some(declarator) + let declarator = cpp_function_name(node.child_by_field_name("declarator")?)?; + // An out-of-line `A::f` is named by its leaf; the qualifier is kept separately. + let (_, leaf) = cpp_split_qualified(declarator, source); + Some(leaf) } else if language == "dart" && matches!( node.kind(), @@ -350,9 +751,20 @@ fn declaration(node: Node<'_>, language: &str, source: &str) -> Option<(String, { return None; } + if language == "cpp" { + return Some((cpp_leaf_name(name, source)?, NodeKind::Function)); + } Some((text(name, source).to_owned(), NodeKind::Function)) } +fn cpp_leaf_name(name: Node<'_>, source: &str) -> Option { + if name.kind() == "operator_cast" { + let target = name.child_by_field_name("type")?; + return Some(format!("operator {}", text(target, source))); + } + Some(text(name, source).to_owned()) +} + fn lexical_scope(node: Node<'_>, source: &str) -> String { let mut parent = node.parent(); let mut names = Vec::new(); @@ -662,3 +1074,999 @@ fn rust_imports(node: Node<'_>, source: &str) -> Vec<(String, Option, name: &str, source: &str) -> CppDeclaration { + let mut path = cpp_enclosing_path(node, source); + let mut identity = name.to_owned(); + let mut qualifier = String::new(); + if node.kind() == "function_definition" + && let Some(declarator) = node + .child_by_field_name("declarator") + .and_then(cpp_function_name) + { + let (segments, _) = cpp_split_qualified(declarator, source); + identity = text(declarator, source).to_owned(); + qualifier = segments.join("::"); + path.extend(segments); + } + path.push(name.to_owned()); + let mut internal = has_ancestor(node, |n| { + n.kind() == "namespace_definition" && n.child_by_field_name("name").is_none() + }); + if node.kind() == "function_definition" + && qualifier.is_empty() + && !has_ancestor(node, |n| n.kind() == "field_declaration_list") + { + let mut cursor = node.walk(); + internal |= node.named_children(&mut cursor).any(|child| { + child.kind() == "storage_class_specifier" && text(child, source) == "static" + }); + } + CppDeclaration { + identity, + qualifier, + qualified_name: path.join("::"), + internal, + } +} + +/// Qualified names of non-local function prototypes such as `int f();` or an +/// in-class `void m();`. Function-pointer variables and friends are not prototypes. +fn cpp_prototype(node: Node<'_>, source: &str) -> Option { + if !matches!(node.kind(), "declaration" | "field_declaration") + || node + .parent() + .is_some_and(|parent| parent.kind() == "friend_declaration") + || has_ancestor(node, |n| { + matches!(n.kind(), "function_definition" | "lambda_expression") + }) + { + return None; + } + let declarator = cpp_function_name(node.child_by_field_name("declarator")?)?; + let (segments, leaf) = cpp_split_qualified(declarator, source); + let mut path = cpp_enclosing_path(node, source); + path.extend(segments); + path.push(cpp_leaf_name(leaf, source)?); + Some(path.join("::")) +} + +/// The declarator naming a function, or `None` when no function declarator +/// applies to the name (plain variables and function-pointer variables). +fn cpp_function_name(mut declarator: Node<'_>) -> Option> { + let mut function = false; + loop { + match declarator.kind() { + "function_declarator" => { + function = true; + declarator = declarator.child_by_field_name("declarator")?; + } + // `operator T()` names itself; its inner declarator is abstract. + "operator_cast" => return Some(declarator), + // Out-of-line `A::operator T()` has no function declarator wrapper. + "qualified_identifier" if !function => { + let mut leaf = declarator; + while let Some(name) = leaf.child_by_field_name("name") { + leaf = name; + } + return (leaf.kind() == "operator_cast").then_some(declarator); + } + // Pointers/references inside a function declarator form function pointers. + "pointer_declarator" | "reference_declarator" if function => return None, + // tree-sitter-cpp 0.23.4 exposes no fields on these wrappers. + "reference_declarator" | "parenthesized_declarator" => { + let mut cursor = declarator.walk(); + declarator = declarator.named_children(&mut cursor).last()?; + } + _ => match declarator.child_by_field_name("declarator") { + Some(inner) => declarator = inner, + None => break, + }, + } + } + function.then_some(declarator) +} + +fn cpp_split_qualified<'a>(mut node: Node<'a>, source: &str) -> (Vec, Node<'a>) { + let mut segments = Vec::new(); + while node.kind() == "qualified_identifier" { + if let Some(scope) = node.child_by_field_name("scope") { + segments.push(strip_template_arguments(text(scope, source))); + } + match node.child_by_field_name("name") { + Some(name) => node = name, + None => break, + } + } + (segments, node) +} + +/// Named namespaces and classes enclosing a C++ node, outermost first. +/// Anonymous namespaces add no name; they make members internal instead. +fn cpp_enclosing_path(node: Node<'_>, source: &str) -> Vec { + let mut names = Vec::new(); + let mut parent = node.parent(); + while let Some(n) = parent { + if matches!( + n.kind(), + "namespace_definition" | "class_specifier" | "struct_specifier" | "union_specifier" + ) && let Some(name) = n.child_by_field_name("name") + { + names.push(strip_template_arguments(text(name, source))); + } + parent = n.parent(); + } + names.reverse(); + names +} + +fn has_ancestor(node: Node<'_>, predicate: impl Fn(Node<'_>) -> bool) -> bool { + let mut parent = node.parent(); + while let Some(n) = parent { + if predicate(n) { + return true; + } + parent = n.parent(); + } + false +} + +fn strip_template_arguments(value: &str) -> String { + if value.contains("operator") { + return value.to_owned(); + } + let mut depth = 0_usize; + let mut result = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '<' => depth += 1, + '>' => depth = depth.saturating_sub(1), + _ if depth == 0 && !character.is_whitespace() => result.push(character), + _ => {} + } + } + result +} + +fn is_cpp_cast(name: &str) -> bool { + matches!( + name, + "static_cast" | "dynamic_cast" | "const_cast" | "reinterpret_cast" + ) +} + +type LocalTypes = HashMap>>; + +/// Receiver evidence for `receiver.f()` / `receiver->f()`. Only explicitly +/// declared variable types are used; expressions and `auto` without a +/// constructing initializer remain unknown. +fn cpp_receiver( + call: Node<'_>, + function: Node<'_>, + source: &str, + local_types: &mut LocalTypes, +) -> Receiver { + let arrow = function + .child_by_field_name("operator") + .is_some_and(|operator| text(operator, source) == "->"); + let Some(argument) = function.child_by_field_name("argument") else { + return Receiver::Unknown; + }; + match argument.kind() { + "identifier" => { + let name = text(argument, source); + let mut scope = call.parent(); + while let Some(n) = scope { + if n.kind() == "function_definition" { + break; + } + scope = n.parent(); + } + let declared = scope.and_then(|scope| { + local_types + .entry(scope.id()) + .or_insert_with(|| cpp_local_types(scope, source)) + .get(name)? + .iter() + .filter(|(start, _)| *start < call.start_byte()) + .max_by_key(|(start, _)| *start) + .map(|(_, ty)| ty.accessed(arrow)) + }); + match declared { + Some(ty) if ty == GENERIC_TYPE => Receiver::Unknown, + Some(ty) => Receiver::Declared(ty), + None => Receiver::Field { + name: name.to_owned(), + arrow, + }, + } + } + "field_expression" + if argument + .child_by_field_name("argument") + .is_some_and(|inner| inner.kind() == "this") => + { + match argument.child_by_field_name("field") { + Some(field) => Receiver::Field { + name: text(field, source).to_owned(), + arrow, + }, + None => Receiver::Unknown, + } + } + _ => Receiver::Unknown, + } +} + +/// Parameters and locals declared anywhere in a function (including nested +/// lambdas and blocks), keyed by name with their declaration offsets. +fn cpp_local_types(function: Node<'_>, source: &str) -> HashMap> { + let generics = cpp_template_parameters(function, source); + let mut result: HashMap> = HashMap::new(); + let mut stack = vec![function]; + while let Some(node) = stack.pop() { + if matches!( + node.kind(), + "parameter_declaration" + | "optional_parameter_declaration" + | "declaration" + | "for_range_loop" + ) && let Some(ty) = node.child_by_field_name("type") + { + let mut cursor = node.walk(); + for declarator in node.children_by_field_name("declarator", &mut cursor) { + if let Some((name, value)) = cpp_variable_name(declarator, source) + && let Some(ty) = cpp_type(ty, value, source) + { + result + .entry(name) + .or_default() + .push((node.start_byte(), mask_generic(ty, &generics))); + } + } + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + result +} + +fn cpp_fields(node: Node<'_>, source: &str) -> Vec<(String, String, CppType)> { + let mut result = Vec::new(); + let Some(ty) = node.child_by_field_name("type") else { + return result; + }; + let class = cpp_enclosing_path(node, source).join("::"); + if class.is_empty() || has_ancestor(node, |n| n.kind() == "function_definition") { + return result; + } + let generics = cpp_template_parameters(node, source); + let mut cursor = node.walk(); + for declarator in node.children_by_field_name("declarator", &mut cursor) { + if cpp_function_name(declarator).is_none() + && let Some((name, value)) = cpp_variable_name(declarator, source) + && let Some(ty) = cpp_type(ty, value, source) + { + result.push((class.clone(), name, mask_generic(ty, &generics))); + } + } + result +} + +/// Marker type for a template parameter: its concrete type is unknown. +pub(crate) const GENERIC_TYPE: &str = ""; + +fn mask_generic(ty: CppType, generics: &[String]) -> CppType { + let generic = |name: &str| generics.iter().any(|g| name.split("::").next() == Some(g)); + if generic(&ty.name) || ty.pointee.as_deref().is_some_and(generic) { + CppType { + name: GENERIC_TYPE.into(), + pointee: Some(GENERIC_TYPE.into()), + } + } else { + ty + } +} + +/// Type parameter names of every template enclosing (or wrapping) `node`. +fn cpp_template_parameters(node: Node<'_>, source: &str) -> Vec { + let mut names = Vec::new(); + let mut current = Some(node); + while let Some(n) = current { + if n.kind() == "template_declaration" + && let Some(parameters) = n.child_by_field_name("parameters") + { + let mut cursor = parameters.walk(); + for parameter in parameters.named_children(&mut cursor) { + let name = parameter.child_by_field_name("name").or_else(|| { + let mut cursor = parameter.walk(); + parameter + .named_children(&mut cursor) + .find(|child| child.kind() == "type_identifier") + }); + if parameter.kind().contains("type_parameter") + && let Some(name) = name + { + names.push(text(name, source).to_owned()); + } + } + } + current = n.parent(); + } + names +} + +/// `using A = T;` and `typedef T A;` at namespace or class scope. +fn cpp_alias(node: Node<'_>, source: &str) -> Option<(String, CppType)> { + let (name, ty) = match node.kind() { + "alias_declaration" => ( + node.child_by_field_name("name")?, + node.child_by_field_name("type")? + .child_by_field_name("type")?, + ), + "type_definition" => { + let mut declarator = node.child_by_field_name("declarator")?; + while let Some(inner) = declarator.child_by_field_name("declarator") { + declarator = inner; + } + (declarator, node.child_by_field_name("type")?) + } + _ => return None, + }; + if has_ancestor(node, |n| n.kind() == "function_definition") { + return None; + } + let mut path = cpp_enclosing_path(node, source); + path.push(text(name, source).to_owned()); + Some((path.join("::"), cpp_type(ty, None, source)?)) +} + +fn cpp_variable_name<'a>( + mut declarator: Node<'a>, + source: &str, +) -> Option<(String, Option>)> { + let mut value = None; + loop { + match declarator.kind() { + "identifier" | "field_identifier" => { + return Some((text(declarator, source).to_owned(), value)); + } + "init_declarator" => { + value = declarator.child_by_field_name("value"); + declarator = declarator.child_by_field_name("declarator")?; + } + "pointer_declarator" | "array_declarator" => { + declarator = declarator.child_by_field_name("declarator")?; + } + "reference_declarator" => { + let mut cursor = declarator.walk(); + declarator = declarator.named_children(&mut cursor).last()?; + } + _ => return None, + } + } +} + +/// Class-like type named by a declaration. `auto` is typed only from +/// `new T(...)` or `std::make_unique/make_shared(...)` initializers. +fn cpp_type(ty: Node<'_>, value: Option>, source: &str) -> Option { + match ty.kind() { + "type_identifier" => Some(CppType { + name: text(ty, source).to_owned(), + pointee: None, + }), + "struct_specifier" | "class_specifier" => Some(CppType { + name: text(ty.child_by_field_name("name")?, source).to_owned(), + pointee: None, + }), + "qualified_identifier" | "template_type" => { + let mut leaf = ty; + while leaf.kind() == "qualified_identifier" { + leaf = leaf.child_by_field_name("name")?; + } + let pointee = (leaf.kind() == "template_type" + && leaf + .child_by_field_name("name") + .is_some_and(|name| matches!(text(name, source), "unique_ptr" | "shared_ptr"))) + .then(|| cpp_first_template_type(leaf, source)) + .flatten(); + Some(CppType { + name: strip_template_arguments(text(ty, source)), + pointee, + }) + } + "placeholder_type_specifier" => { + let value = value?; + match value.kind() { + "new_expression" => cpp_type(value.child_by_field_name("type")?, None, source), + "call_expression" => { + let mut function = value.child_by_field_name("function")?; + while function.kind() == "qualified_identifier" { + function = function.child_by_field_name("name")?; + } + let factory = text(function.child_by_field_name("name")?, source); + let pointer = match factory { + "make_unique" => "std::unique_ptr", + "make_shared" => "std::shared_ptr", + _ => return None, + }; + Some(CppType { + name: pointer.into(), + pointee: Some(cpp_first_template_type(function, source)?), + }) + } + _ => None, + } + } + _ => None, + } +} + +fn cpp_first_template_type(node: Node<'_>, source: &str) -> Option { + let arguments = node.child_by_field_name("arguments")?; + let first = arguments.named_child(0)?; + let ty = first.child_by_field_name("type")?; + Some(cpp_type(ty, None, source)?.name) +} + +/// Rust `impl T` / `impl Trait for T` / `trait Trait` enclosing a declaration. +fn rust_impl_context(node: Node<'_>, source: &str) -> (Option, Option) { + let mut parent = node.parent(); + while let Some(n) = parent { + match n.kind() { + "impl_item" => { + return ( + n.child_by_field_name("type") + .and_then(|ty| rust_type_name(ty, source)), + n.child_by_field_name("trait") + .and_then(|ty| rust_type_name(ty, source)), + ); + } + "trait_item" => { + return ( + None, + n.child_by_field_name("name") + .map(|name| text(name, source).to_owned()), + ); + } + "function_item" | "closure_expression" => return (None, None), + _ => parent = n.parent(), + } + } + (None, None) +} + +/// Leaf type name with references and generic arguments removed. +fn rust_type_name(ty: Node<'_>, source: &str) -> Option { + match ty.kind() { + "type_identifier" => Some(text(ty, source).to_owned()), + "scoped_type_identifier" => Some(text(ty.child_by_field_name("name")?, source).to_owned()), + "generic_type" | "reference_type" => { + rust_type_name(ty.child_by_field_name("type")?, source) + } + _ => None, + } +} + +/// A Rust declared type. Method calls auto-dereference `Box`/`Rc`/`Arc`, so +/// `pointee` is the receiver type used for `.` calls through them. +fn rust_type(ty: Node<'_>, source: &str, generics: &[String]) -> Option { + match ty.kind() { + "reference_type" => rust_type(ty.child_by_field_name("type")?, source, generics), + "generic_type" => { + let name = rust_type_name(ty.child_by_field_name("type")?, source)?; + let pointee = matches!(name.as_str(), "Box" | "Rc" | "Arc") + .then(|| { + let argument = ty.child_by_field_name("type_arguments")?.named_child(0)?; + Some(rust_type(argument, source, generics)?.name) + }) + .flatten(); + Some(CppType { name, pointee }) + } + // `dyn Trait` / `impl Trait` dispatch through the trait's declared method. + "dynamic_type" | "abstract_type" => { + let mut cursor = ty.walk(); + let bound = ty.named_children(&mut cursor).next()?; + Some(CppType { + name: rust_type_name(bound, source)?, + pointee: None, + }) + } + "type_identifier" | "scoped_type_identifier" => { + let name = rust_type_name(ty, source)?; + // A generic parameter names no concrete repository type. + (!generics.contains(&name)).then_some(CppType { + name, + pointee: None, + }) + } + _ => None, + } +} + +type RustLocals = HashMap>>; + +/// Receiver evidence for a Rust method call `value.m()`. `position` is a node +/// of the original tree used to find the enclosing function's declarations. +fn rust_receiver( + value: Node<'_>, + value_source: &str, + position: Node<'_>, + source: &str, + locals: &mut RustLocals, +) -> Receiver { + match value.kind() { + "self" => Receiver::SelfValue, + "field_expression" + if value + .child_by_field_name("value") + .is_some_and(|inner| inner.kind() == "self") => + { + match value.child_by_field_name("field") { + Some(field) => Receiver::Field { + name: text(field, value_source).to_owned(), + arrow: false, + }, + None => Receiver::Unknown, + } + } + "identifier" => { + let name = text(value, value_source); + let mut function = position.parent(); + while let Some(n) = function { + if n.kind() == "function_item" { + break; + } + function = n.parent(); + } + function + .and_then(|function| { + locals + .entry(function.id()) + .or_insert_with(|| rust_local_types(function, source)) + .get(name)? + .iter() + .filter(|(start, _)| *start < position.start_byte()) + .max_by_key(|(start, _)| *start) + .map(|(_, receiver)| receiver.clone()) + }) + .unwrap_or(Receiver::Unknown) + } + _ => Receiver::Unknown, + } +} + +fn rust_local_types(function: Node<'_>, source: &str) -> HashMap> { + let mut generics = Vec::new(); + let mut ancestor = Some(function); + while let Some(n) = ancestor { + if let Some(parameters) = n.child_by_field_name("type_parameters") { + let mut cursor = parameters.walk(); + for parameter in parameters.named_children(&mut cursor) { + if let Some(name) = parameter + .child_by_field_name("name") + .or_else(|| parameter.named_child(0)) + { + generics.push(text(name, source).to_owned()); + } + } + } + ancestor = n.parent(); + } + let mut result: HashMap> = HashMap::new(); + let mut stack = vec![function]; + while let Some(node) = stack.pop() { + let binding = match node.kind() { + "parameter" | "let_declaration" => node + .child_by_field_name("pattern") + .filter(|pattern| pattern.kind() == "identifier") + .and_then(|pattern| { + let receiver = match node.child_by_field_name("type") { + Some(ty) => rust_type(ty, source, &generics) + .map(|ty| Receiver::Declared(ty.accessed(true))), + None => node + .child_by_field_name("value") + .and_then(|value| rust_value_type(value, source)), + }?; + Some((text(pattern, source).to_owned(), receiver)) + }), + _ => None, + }; + if let Some((name, receiver)) = binding { + result + .entry(name) + .or_default() + .push((node.start_byte(), receiver)); + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + result +} + +/// `Type { .. }` or `Type::func(..)` (optionally followed by `?`). +fn rust_value_type(value: Node<'_>, source: &str) -> Option { + let (value, fallible) = match value.kind() { + "try_expression" => (value.named_child(0)?, true), + _ => (value, false), + }; + match value.kind() { + "struct_expression" => Some(Receiver::Declared(rust_type_name( + value.child_by_field_name("name")?, + source, + )?)), + "call_expression" => { + let function = value.child_by_field_name("function")?; + let function = match function.kind() { + "generic_function" => function.child_by_field_name("function")?, + _ => function, + }; + if function.kind() != "scoped_identifier" { + return None; + } + let path = function.child_by_field_name("path")?; + let ty = match path.kind() { + "scoped_identifier" => text(path.child_by_field_name("name")?, source), + "generic_type" => text(path.child_by_field_name("type")?, source), + _ => text(path, source), + }; + Some(Receiver::Returned { + ty: ty.to_owned(), + func: text(function.child_by_field_name("name")?, source).to_owned(), + fallible, + }) + } + _ => None, + } +} + +fn rust_fields(node: Node<'_>, source: &str) -> Vec<(String, String, CppType)> { + let mut struct_item = node.parent(); + while let Some(n) = struct_item { + if n.kind() == "struct_item" { + break; + } + struct_item = n.parent(); + } + let (Some(owner), Some(name), Some(ty)) = ( + struct_item + .and_then(|n| n.child_by_field_name("name")) + .map(|name| text(name, source).to_owned()), + node.child_by_field_name("name"), + node.child_by_field_name("type"), + ) else { + return Vec::new(); + }; + rust_type(ty, source, &[]) + .map(|ty| vec![(owner, text(name, source).to_owned(), ty)]) + .unwrap_or_default() +} + +struct MacroContext<'a, 'tree> { + position: Node<'tree>, + owner: Option, + scope: &'a str, + self_type: &'a Option, + source: &'a str, +} + +/// Expression macros whose arguments are ordinary Rust expressions. +fn is_expression_macro(name: &str) -> bool { + matches!( + name, + "assert" + | "assert_eq" + | "assert_ne" + | "debug_assert" + | "debug_assert_eq" + | "debug_assert_ne" + | "vec" + | "format" + | "format_args" + | "print" + | "println" + | "eprint" + | "eprintln" + | "write" + | "writeln" + | "panic" + | "unreachable" + | "todo" + | "unimplemented" + | "dbg" + | "anyhow" + | "bail" + | "ensure" + ) +} + +/// Calls inside an allowlisted expression macro, recovered by re-parsing its +/// token tree as an array expression. Nested allowlisted macros are expanded +/// the same way; any other macro body stays opaque. +fn rust_macro_calls( + context: &MacroContext<'_, '_>, + parser: &mut Parser, + locals: &mut RustLocals, + diagnostics: &mut Vec, +) -> Vec { + let mut relationships = Vec::new(); + let mut pending = vec![( + text(context.position, context.source).to_owned(), + context.position.start_position().row, + )]; + while let Some((invocation, base_row)) = pending.pop() { + let Some(open) = invocation.find('!') else { + continue; + }; + let name = invocation[..open].rsplit("::").next().unwrap_or("").trim(); + let body = invocation[open + 1..].trim(); + if !is_expression_macro(name) || body.len() < 2 { + continue; + } + let wrapped = format!( + "fn __brick_macro() {{ let _ = [{}]; }}", + &body[1..body.len() - 1] + ); + let Some(tree) = parser.parse(&wrapped, None) else { + continue; + }; + if tree.root_node().has_error() { + diagnostics.push(format!( + "{name}! body at line {} is not an expression list; its calls are omitted", + base_row + 1 + )); + continue; + } + let mut stack = vec![tree.root_node()]; + while let Some(node) = stack.pop() { + if node.kind() == "macro_invocation" { + pending.push(( + text(node, &wrapped).to_owned(), + base_row + node.start_position().row, + )); + continue; + } + if node.kind() == "call_expression" + && let Some(function) = node.child_by_field_name("function") + && let Some((target, member)) = call_target(function, &wrapped) + { + let receiver = member.then(|| { + function + .child_by_field_name("value") + .map(|value| { + rust_receiver(value, &wrapped, context.position, context.source, locals) + }) + .unwrap_or(Receiver::Unknown) + }); + relationships.push(Relationship { + shadowed: !member + && parameter_shadows(context.position, &target, context.source), + owner: context.owner, + target, + member, + kind: EdgeKind::Calls, + line: base_row + node.start_position().row + 1, + scope: context.scope.to_owned(), + receiver_this: false, + lookup_scope: None, + receiver, + self_type: context.self_type.clone(), + extraction: Some("macro-reparse"), + }); + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + } + relationships +} + +/// Whether an identifier is used as a value/name rather than as the callee of +/// a call or the name of its own declaration. +fn is_reference(node: Node<'_>) -> bool { + let mut current = node; + while let Some(parent) = current.parent() { + match parent.kind() { + "qualified_identifier" | "scoped_identifier" => { + if parent.child_by_field_name("name") != Some(current) { + return true; + } + } + "template_function" | "generic_function" => { + if parent.child_by_field_name("name") != Some(current) + && parent.child_by_field_name("function") != Some(current) + { + return true; + } + } + // A member position names a field or method of a value, never a free function. + "field_expression" => return parent.child_by_field_name("field") != Some(current), + "call_expression" => return parent.child_by_field_name("function") != Some(current), + "function_declarator" => { + return parent.child_by_field_name("declarator") != Some(current); + } + "function_item" | "function_signature_item" => { + return parent.child_by_field_name("name") != Some(current); + } + // Only the bound name is a binding; initializers and iterables are uses. + kind if is_binding_parent(kind) => { + return ["value", "default_value", "right"] + .iter() + .any(|field| parent.child_by_field_name(field) == Some(current)); + } + // An attribute's own path (`#[test]`, `#[serde(..)]`) is not a use. + "attribute" => return false, + // Importing a name is not using it. + "use_declaration" | "use_list" | "scoped_use_list" | "use_as_clause" + | "using_declaration" => return false, + _ => return true, + } + current = parent; + } + true +} + +/// C++ `virtual` (an anonymous token) or an `override`/`final` specifier. +fn cpp_is_virtual(node: Node<'_>) -> bool { + let mut cursor = node.walk(); + if node + .children(&mut cursor) + .any(|child| child.kind() == "virtual") + { + return true; + } + let mut declarator = node.child_by_field_name("declarator"); + while let Some(current) = declarator { + if current.kind() == "function_declarator" { + let mut cursor = current.walk(); + return current + .named_children(&mut cursor) + .any(|child| child.kind() == "virtual_specifier"); + } + declarator = current.child_by_field_name("declarator"); + } + false +} + +/// Syntax whose direct identifier children introduce local bindings. +fn is_binding_parent(kind: &str) -> bool { + matches!( + kind, + "let_declaration" + | "parameter" + | "closure_parameters" + | "for_expression" + | "tuple_pattern" + | "struct_pattern" + | "tuple_struct_pattern" + | "ref_pattern" + | "mut_pattern" + | "field_pattern" + | "slice_pattern" + | "or_pattern" + | "captured_pattern" + | "let_condition" + | "match_pattern" + | "init_declarator" + | "parameter_declaration" + | "optional_parameter_declaration" + | "declaration" + | "pointer_declarator" + | "reference_declarator" + | "array_declarator" + | "for_range_loop" + ) +} + +/// Every name a function binds locally (parameters, lets, patterns, declarators). +fn local_bindings<'a>(function: Node<'_>, source: &'a str) -> std::collections::HashSet<&'a str> { + let mut names = std::collections::HashSet::new(); + let mut stack = vec![function]; + while let Some(node) = stack.pop() { + if node.kind() == "identifier" + && node.parent().is_some_and(|parent| { + is_binding_parent(parent.kind()) + && ["value", "default_value", "right"] + .iter() + .all(|field| parent.child_by_field_name(field) != Some(node)) + }) + { + names.insert(&source[node.byte_range()]); + } + let mut cursor = node.walk(); + stack.extend(node.named_children(&mut cursor)); + } + names +} + +/// Outer attribute paths on a Rust item that could register it with a +/// framework or harness. Lint, doc, cfg, and codegen hints are ignored. +fn rust_outer_attributes(node: Node<'_>, source: &str) -> Vec { + let mut result = Vec::new(); + let mut sibling = node.prev_named_sibling(); + while let Some(item) = sibling.filter(|item| item.kind() == "attribute_item") { + if let Some(path) = item + .named_child(0) + .and_then(|attribute| attribute.named_child(0)) + { + let path = text(path, source); + if !matches!( + path, + "doc" + | "allow" + | "warn" + | "deny" + | "expect" + | "forbid" + | "cfg" + | "cfg_attr" + | "inline" + | "cold" + | "must_use" + | "deprecated" + | "track_caller" + | "rustfmt::skip" + | "allow_internal_unstable" + ) { + result.push(path.to_owned()); + } + } + sibling = item.prev_named_sibling(); + } + result.sort(); + result +} + +/// Calls hidden by C++'s "most vexing parse": inside a function body, +/// `T obj(f());` parses as a local function declaration whose parameter has +/// type `f` and an abstract function declarator. Local function prototypes +/// are vanishingly rare, so each such parameter type is a call to `f`. +fn cpp_vexing_calls(node: Node<'_>, source: &str) -> Vec<(String, usize)> { + let mut calls = Vec::new(); + if node.kind() != "declaration" + || node + .child_by_field_name("declarator") + .is_none_or(|declarator| declarator.kind() != "function_declarator") + || !has_ancestor(node, |n| n.kind() == "function_definition") + { + return calls; + } + let mut stack = vec![ + node.child_by_field_name("declarator") + .expect("checked above"), + ]; + while let Some(current) = stack.pop() { + if current.kind() == "parameter_declaration" + && current + .child_by_field_name("declarator") + .is_some_and(|declarator| declarator.kind() == "abstract_function_declarator") + && let Some(ty) = current.child_by_field_name("type") + && matches!(ty.kind(), "type_identifier" | "qualified_identifier") + { + calls.push(( + strip_template_arguments(text(ty, source)), + current.start_position().row + 1, + )); + } + let mut cursor = current.walk(); + stack.extend(current.named_children(&mut cursor)); + } + calls +} + +/// tree-sitter-cpp 0.23.4 parses a `= {}` default argument as a compound +/// literal with a zero-width MISSING type. The declaration is otherwise intact. +fn is_benign_missing(node: Node<'_>) -> bool { + node.is_missing() + && node.kind() == "type_identifier" + && node + .parent() + .is_some_and(|parent| parent.kind() == "compound_literal_expression") +} diff --git a/tests/benchmarks/plans/cpp_xtu.json b/tests/benchmarks/plans/cpp_xtu.json new file mode 100644 index 0000000..e9722fb --- /dev/null +++ b/tests/benchmarks/plans/cpp_xtu.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "name": "cpp_xtu", + "corpus": "tests/fixtures/cpp_xtu/cpp", + "repeats": 3, + "direct_binary": true, + "measure_rss": true, + "expect": { + "min_symbols": 23, + "min_dependency_edges": 24, + "cycles": 0 + }, + "source_labels": "labels.json" +} diff --git a/tests/benchmarks/plans/rust_methods.json b/tests/benchmarks/plans/rust_methods.json new file mode 100644 index 0000000..7c93017 --- /dev/null +++ b/tests/benchmarks/plans/rust_methods.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "name": "rust_methods", + "corpus": "tests/fixtures/rust_methods/rust", + "repeats": 3, + "direct_binary": true, + "measure_rss": true, + "expect": { + "min_symbols": 21, + "min_dependency_edges": 23, + "cycles": 0 + }, + "source_labels": "labels.json" +} diff --git a/tests/benchmarks/runner.py b/tests/benchmarks/runner.py index 177def0..b971fc8 100755 --- a/tests/benchmarks/runner.py +++ b/tests/benchmarks/runner.py @@ -412,7 +412,8 @@ def source_language_quality(model: dict, labels: dict) -> dict: if current["id"] in visited: raise AssertionError("cyclic declaration ownership") visited.add(current["id"]) - names.append(current["name"]) + qualifier = current.get("attributes", {}).get("source.qualifier") + names.append(f"{qualifier}::{current['name']}" if qualifier else current["name"]) current = nodes[current["parent"]] scope = node.get("attributes", {}).get("source.scope") return "::".join([node["path"], *([f"scope={scope}"] if scope else []), *reversed(names)]) diff --git a/tests/cpp_xtu.rs b/tests/cpp_xtu.rs new file mode 100644 index 0000000..f2e2ebe --- /dev/null +++ b/tests/cpp_xtu.rs @@ -0,0 +1,328 @@ +use brick::model::{Building, EdgeKind, Node, NodeKind}; +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() +} + +fn function<'a>(model: &'a Building, path: &str, name: &str) -> &'a Node { + model + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.path.as_deref() == Some(path) && n.name == name) + .unwrap_or_else(|| panic!("missing {path}::{name}")) +} + +/// (target node, unresolved reason) for every call edge leaving `source`. +fn calls<'a>(model: &'a Building, source: &Node) -> Vec<(&'a Node, Option)> { + 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(); + let reason = e + .attributes + .get("source.unresolved-reason") + .and_then(|v| v.as_str()) + .map(str::to_owned); + (target, reason) + }) + .collect() +} + +fn single_call<'a>(model: &'a Building, source: &Node) -> (&'a Node, Option) { + let mut found = calls(model, source); + assert_eq!(found.len(), 1, "calls from {}", source.name); + found.remove(0) +} + +#[test] +fn header_prototype_resolves_to_the_paired_definition_across_files() { + let model = scan_files(&[ + ("src/util.h", "int helper(int x);\n"), + ( + "src/util.cpp", + "#include \"util.h\"\nint helper(int x) { return x; }\n", + ), + ( + "src/main.cpp", + "#include \"util.h\"\nint main() { return helper(1); }\n", + ), + // A second program's same-named definition never includes util.h. + ("tools/dup.cpp", "int helper(int x) { return x + 1; }\n"), + ]); + let (target, reason) = single_call(&model, function(&model, "src/main.cpp", "main")); + assert_eq!(target.path.as_deref(), Some("src/util.cpp")); + assert_eq!(reason, None); + let dup = function(&model, "tools/dup.cpp", "helper"); + assert!( + !model + .edges + .iter() + .any(|e| e.kind == EdgeKind::Calls && e.target == dup.id) + ); +} + +#[test] +fn undeclared_and_internal_definitions_in_other_files_stay_unresolved() { + let model = scan_files(&[ + ( + "src/util.cpp", + "static int twice(int x) { return 2 * x; }\nint helper(int x) { return twice(x); }\n", + ), + ( + "src/other.cpp", + "static int twice(int x) { return x + x; }\nint local() { return twice(1); }\nint caller() { return helper(1); }\n", + ), + ]); + let (local, _) = single_call(&model, function(&model, "src/other.cpp", "local")); + assert_eq!(local.path.as_deref(), Some("src/other.cpp")); + assert_eq!( + local + .attributes + .get("source.linkage") + .and_then(|v| v.as_str()), + Some("internal") + ); + let (helper, _) = single_call(&model, function(&model, "src/util.cpp", "helper")); + assert_eq!(helper.path.as_deref(), Some("src/util.cpp")); + // No prototype for helper is visible from other.cpp. + let (target, reason) = single_call(&model, function(&model, "src/other.cpp", "caller")); + assert_eq!(target.kind, NodeKind::External); + assert_eq!(reason.as_deref(), Some("not-visible")); +} + +#[test] +fn out_of_line_methods_use_leaf_names_and_resolve_implicit_and_explicit_this() { + let model = scan_files(&[ + ( + "include/geo/shape.h", + "namespace geo {\nclass Shape {\npublic:\n double area() const;\n int& counter();\nprivate:\n double scale(double x) const;\n};\n}\n", + ), + ( + "src/shape.cpp", + "#include \"geo/shape.h\"\nnamespace geo {\ndouble Shape::area() const {\n return this->scale(2.0)\n + scale(1.0);\n}\ndouble Shape::scale(double x) const { return x; }\nint& Shape::counter() { static int n = 0; return n; }\n}\n", + ), + ]); + let area = function(&model, "src/shape.cpp", "area"); + assert_eq!( + area.attributes + .get("source.qualifier") + .and_then(|v| v.as_str()), + Some("Shape") + ); + assert_eq!( + area.attributes + .get("source.qualified-name") + .and_then(|v| v.as_str()), + Some("geo::Shape::area") + ); + // Both call lines merge into one edge with two pieces of provenance. + let (scale, reason) = single_call(&model, area); + assert_eq!(scale.name, "scale"); + assert_eq!(scale.path.as_deref(), Some("src/shape.cpp")); + assert_eq!(reason, None); + let edge = model + .edges + .iter() + .find(|e| e.source == area.id && e.target == scale.id) + .unwrap(); + assert_eq!(edge.evidence.len(), 2); + // A reference return type no longer leaks into the declaration name. + function(&model, "src/shape.cpp", "counter"); +} + +#[test] +fn macro_ambiguous_external_and_untyped_receivers_are_classified_not_guessed() { + let model = scan_files(&[ + ("src/log.h", "#define LOG_TWICE(x) ((x) * 2)\n"), + ( + "src/main.cpp", + "#include \"log.h\"\n#include \nstruct Box { int size() { return 1; } };\nBox make();\nvoid emit(int) {}\nvoid emit(double) {}\nint main() {\n std::string text;\n emit(1);\n text.size();\n make().size();\n return static_cast(LOG_TWICE(2));\n}\n", + ), + ]); + let main = function(&model, "src/main.cpp", "main"); + let mut reasons: Vec<_> = calls(&model, main) + .into_iter() + .map(|(target, reason)| { + assert_eq!(target.kind, NodeKind::External); + (target.name.clone(), reason.unwrap()) + }) + .collect(); + reasons.sort(); + assert_eq!( + reasons, + [ + ("LOG_TWICE".to_owned(), "macro".to_owned()), + ("emit".to_owned(), "ambiguous".to_owned()), + ("make".to_owned(), "declared-only".to_owned()), + ("size".to_owned(), "receiver-type-external".to_owned()), + ("size".to_owned(), "receiver-type-unknown".to_owned()), + ] + ); +} + +#[test] +fn declared_receivers_fields_and_bases_resolve_members_with_a_label() { + let model = scan_files(&[ + ( + "src/shape.h", + "#include \nstruct Base { int id() const; };\nstruct Shape : Base {\n int area() const;\n int total();\n std::unique_ptr next_;\n Shape* raw_;\n};\n", + ), + ( + "src/shape.cpp", + "#include \"shape.h\"\nint Base::id() const { return 0; }\nint Shape::area() const { return id(); }\nint Shape::total() {\n return next_->area()\n + this->raw_->area();\n}\n", + ), + ( + "src/main.cpp", + "#include \"shape.h\"\nint use(const Shape& ref, Shape* ptr) {\n auto owned = std::make_unique();\n return ref.area()\n + ptr->id()\n + owned->area();\n}\n", + ), + ]); + let targets = |source: &Node| { + let mut found: Vec<_> = calls(&model, source) + .into_iter() + .filter(|(target, _)| !target.name.starts_with("std::")) + .map(|(target, reason)| { + assert_eq!(reason, None, "{} -> {}", source.name, target.name); + (target.path.clone().unwrap(), target.name.clone()) + }) + .collect(); + found.sort(); + found.dedup(); + found + }; + let area = ("src/shape.cpp".to_owned(), "area".to_owned()); + let id = ("src/shape.cpp".to_owned(), "id".to_owned()); + assert_eq!( + targets(function(&model, "src/main.cpp", "use")), + [area.clone(), id.clone()] + ); + assert_eq!( + targets(function(&model, "src/shape.cpp", "total")), + std::slice::from_ref(&area) + ); + // Unqualified lookup inside a class scope reaches in-repository bases. + assert_eq!(targets(function(&model, "src/shape.cpp", "area")), [id]); + let use_fn = function(&model, "src/main.cpp", "use"); + assert!( + model + .edges + .iter() + .filter(|e| e.kind == EdgeKind::Calls && e.source == use_fn.id) + .filter(|e| model + .nodes + .iter() + .any(|n| n.id == e.target && n.kind == NodeKind::Function)) + .all(|e| e + .attributes + .get("source.resolution") + .and_then(|v| v.as_str()) + == Some("declared-receiver-type")) + ); +} + +#[test] +fn conversion_operators_are_declarations_matching_their_prototypes() { + let model = scan_files(&[( + "src/flag.cpp", + "struct Flag {\n explicit operator bool() const;\n};\nFlag::operator bool() const { return true; }\n", + )]); + let operator = function(&model, "src/flag.cpp", "operator bool"); + assert_eq!( + operator + .attributes + .get("source.qualified-name") + .and_then(|v| v.as_str()), + Some("Flag::operator bool") + ); +} + +#[test] +fn resolution_metrics_count_call_sites_by_outcome() { + let model = scan_files(&[( + "src/main.cpp", + "struct Box { int get() { return 1; } };\nBox make();\nint one() { return 1; }\nint main() {\n return one()\n + one()\n + make().get();\n}\n", + )]); + let cpp = &model.metrics.resolution["cpp"]; + assert_eq!(cpp.call_site_count, 4); + assert_eq!(cpp.resolved_call_site_count, 2); + assert_eq!( + cpp.unresolved_call_sites_by_reason["receiver-type-unknown"], + 1 + ); + assert_eq!(cpp.unresolved_call_sites_by_reason["declared-only"], 1); + assert_eq!(cpp.function_count, 3); + // main has no callers and Box::get is only reachable through an untyped receiver. + assert_eq!(cpp.functions_without_resolved_callers, 2); +} + +#[test] +fn using_directives_and_declarations_extend_lookup_only_where_visible() { + let model = scan_files(&[ + ( + "include/lib/tune.h", + "namespace lib::daemon {\nstruct Tune { static int ticks(); };\nint helper();\n}\n", + ), + ( + "src/tune.cpp", + "#include \"lib/tune.h\"\nnamespace lib::daemon {\nint Tune::ticks() { return 1; }\nint helper() { return 2; }\n}\n", + ), + ( + "tests/tune_test.cpp", + "#include \"lib/tune.h\"\nusing namespace lib::daemon;\nint check() {\n Tune tune;\n return Tune::ticks()\n + tune.ticks();\n}\n", + ), + ( + "tests/alias_test.cpp", + "#include \"lib/tune.h\"\nusing lib::daemon::helper;\nint alias() { return helper(); }\n", + ), + // Clean negative: the directive lives in a file this caller never includes. + ("tests/other_using.cpp", "using namespace lib::daemon;\n"), + ( + "tests/negative_test.cpp", + "#include \"lib/tune.h\"\nint negative() { return helper(); }\n", + ), + ]); + let (ticks, reason) = single_call(&model, function(&model, "tests/tune_test.cpp", "check")); + assert_eq!(ticks.path.as_deref(), Some("src/tune.cpp")); + assert_eq!(ticks.name, "ticks"); + assert_eq!(reason, None); + let (helper, reason) = single_call(&model, function(&model, "tests/alias_test.cpp", "alias")); + assert_eq!(helper.path.as_deref(), Some("src/tune.cpp")); + assert_eq!(reason, None); + let (target, reason) = single_call( + &model, + function(&model, "tests/negative_test.cpp", "negative"), + ); + assert_eq!(target.kind, NodeKind::External); + assert_eq!(reason.as_deref(), Some("not-visible")); +} + +#[test] +fn brace_default_arguments_do_not_hide_prototypes_or_report_errors() { + let model = scan_files(&[ + ( + "src/api.h", + "struct Config {};\nint store(const Config& config = {});\n", + ), + ( + "src/api.cpp", + "#include \"api.h\"\nint store(const Config& config) { return 0; }\n", + ), + ( + "src/main.cpp", + "#include \"api.h\"\nint main() { return store(); }\n", + ), + ]); + let (target, reason) = single_call(&model, function(&model, "src/main.cpp", "main")); + assert_eq!(target.path.as_deref(), Some("src/api.cpp")); + assert_eq!(reason, None); + assert!(model.diagnostics.is_empty(), "{:?}", model.diagnostics); +} diff --git a/tests/fixtures/cpp_xtu/cpp/include/geo/shape.h b/tests/fixtures/cpp_xtu/cpp/include/geo/shape.h new file mode 100644 index 0000000..07cd629 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/include/geo/shape.h @@ -0,0 +1,21 @@ +#pragma once + +namespace geo { +class Shape { +public: + Shape(); + double area() const; + static Shape unit(); + int& counter(); + +private: + double scale(double x) const; +}; + +class Circle : public Shape { +public: + double radius() const; +}; + +double total(const Shape& shape); +} // namespace geo diff --git a/tests/fixtures/cpp_xtu/cpp/labels.json b/tests/fixtures/cpp_xtu/cpp/labels.json new file mode 100644 index 0000000..f0f88a1 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/labels.json @@ -0,0 +1,223 @@ +{ + "language_prefixes": { + "headers": "include/", + "sources": "src/", + "tools": "tools/" + }, + "symbols": [ + [ + "include/geo/shape.h::scope=geo::Shape", + "class" + ], + [ + "src/shape.cpp::scope=geo::Shape::Shape", + "function" + ], + [ + "src/shape.cpp::scope=geo::Shape::area", + "function" + ], + [ + "src/shape.cpp::scope=geo::Shape::scale", + "function" + ], + [ + "src/shape.cpp::scope=geo::Shape::unit", + "function" + ], + [ + "src/shape.cpp::scope=geo::Shape::counter", + "function" + ], + [ + "src/shape.cpp::scope=geo::total", + "function" + ], + [ + "src/util.cpp::twice", + "function" + ], + [ + "src/util.cpp::helper", + "function" + ], + [ + "src/main.cpp::main", + "function" + ], + [ + "src/other.cpp::twice", + "function" + ], + [ + "src/other.cpp::orphan", + "function" + ], + [ + "src/other.cpp::undeclared_caller", + "function" + ], + [ + "src/overload.cpp::log_value", + "function" + ], + [ + "src/overload.cpp::log_value", + "function" + ], + [ + "src/overload.cpp::use_log", + "function" + ], + [ + "src/legacy.c::add", + "function" + ], + [ + "src/legacy.c::legacy_sum", + "function" + ], + [ + "tools/dup.cpp::helper", + "function" + ], + [ + "include/geo/shape.h::scope=geo::Circle", + "class" + ], + [ + "src/shape.cpp::scope=geo::Circle::radius", + "function" + ], + [ + "src/buffer.h::Buffer", + "class" + ], + [ + "src/buffer.h::Buffer::size", + "function" + ] + ], + "relationships": [ + [ + "src/shape.cpp", + "imports", + "include/geo/shape.h" + ], + [ + "src/util.cpp", + "imports", + "src/util.h" + ], + [ + "src/main.cpp", + "imports", + "include/geo/shape.h" + ], + [ + "src/main.cpp", + "imports", + "src/util.h" + ], + [ + "src/shape.cpp::scope=geo::Shape::area", + "calls", + "src/shape.cpp::scope=geo::Shape::scale" + ], + [ + "src/shape.cpp::scope=geo::Shape::unit", + "calls", + "src/shape.cpp::scope=geo::Shape::Shape" + ], + [ + "src/util.cpp::helper", + "calls", + "src/util.cpp::twice" + ], + [ + "src/main.cpp::main", + "calls", + "src/shape.cpp::scope=geo::Shape::unit" + ], + [ + "src/main.cpp::main", + "calls", + "src/util.cpp::helper" + ], + [ + "src/main.cpp::main", + "calls", + "src/shape.cpp::scope=geo::total" + ], + [ + "src/main.cpp::main", + "calls", + "external:cpp:LOG_TWICE" + ], + [ + "src/other.cpp::orphan", + "calls", + "src/other.cpp::twice" + ], + [ + "src/other.cpp::undeclared_caller", + "calls", + "external:cpp:helper" + ], + [ + "src/overload.cpp::use_log", + "calls", + "external:cpp:log_value" + ], + [ + "src/legacy.c::legacy_sum", + "calls", + "src/legacy.c::add" + ], + [ + "src/main.cpp", + "imports", + "src/buffer.h" + ], + [ + "src/main.cpp", + "imports", + "external:cpp:string" + ], + [ + "include/geo/shape.h::scope=geo::Circle", + "inherits", + "include/geo/shape.h::scope=geo::Shape" + ], + [ + "src/shape.cpp::scope=geo::Circle::radius", + "calls", + "src/shape.cpp::scope=geo::Shape::area" + ], + [ + "src/shape.cpp::scope=geo::total", + "calls", + "src/shape.cpp::scope=geo::Shape::area" + ], + [ + "src/main.cpp::main", + "calls", + "src/shape.cpp::scope=geo::Shape::counter" + ], + [ + "src/main.cpp::main", + "calls", + "external:cpp:size" + ], + [ + "src/main.cpp::main", + "calls", + "external:cpp:size" + ], + [ + "src/main.cpp::main", + "calls", + "external:cpp:make_buffer" + ] + ] +} diff --git a/tests/fixtures/cpp_xtu/cpp/src/buffer.h b/tests/fixtures/cpp_xtu/cpp/src/buffer.h new file mode 100644 index 0000000..174d062 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/buffer.h @@ -0,0 +1,6 @@ +#pragma once + +// Clean negative: a repository size() that std::string and untyped receivers must not reach. +struct Buffer { + int size() const { return 0; } +}; diff --git a/tests/fixtures/cpp_xtu/cpp/src/legacy.c b/tests/fixtures/cpp_xtu/cpp/src/legacy.c new file mode 100644 index 0000000..68d7b4d --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/legacy.c @@ -0,0 +1,3 @@ +static int add(int a, int b) { return a + b; } + +int legacy_sum(void) { return add(1, 2); } diff --git a/tests/fixtures/cpp_xtu/cpp/src/main.cpp b/tests/fixtures/cpp_xtu/cpp/src/main.cpp new file mode 100644 index 0000000..4758441 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/main.cpp @@ -0,0 +1,16 @@ +#include "geo/shape.h" +#include "buffer.h" +#include "util.h" +#include + +Buffer make_buffer(); + +int main() { + std::string text; + text.size(); + make_buffer().size(); + geo::Shape shape = geo::Shape::unit(); + int n = helper(3); + shape.counter(); + return static_cast(geo::total(shape)) + n + LOG_TWICE(n); +} diff --git a/tests/fixtures/cpp_xtu/cpp/src/other.cpp b/tests/fixtures/cpp_xtu/cpp/src/other.cpp new file mode 100644 index 0000000..a14e017 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/other.cpp @@ -0,0 +1,6 @@ +// Clean negative: same-named internal function and an undeclared cross-TU call. +static int twice(int x) { return x + x; } + +int orphan() { return twice(1); } + +int undeclared_caller() { return helper(1); } diff --git a/tests/fixtures/cpp_xtu/cpp/src/overload.cpp b/tests/fixtures/cpp_xtu/cpp/src/overload.cpp new file mode 100644 index 0000000..75f2afc --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/overload.cpp @@ -0,0 +1,4 @@ +void log_value(int) {} +void log_value(double) {} + +void use_log() { log_value(1); } diff --git a/tests/fixtures/cpp_xtu/cpp/src/shape.cpp b/tests/fixtures/cpp_xtu/cpp/src/shape.cpp new file mode 100644 index 0000000..77e9696 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/shape.cpp @@ -0,0 +1,20 @@ +#include "geo/shape.h" + +namespace geo { +Shape::Shape() {} + +double Shape::area() const { return this->scale(2.0) + scale(1.0); } + +double Shape::scale(double x) const { return x; } + +Shape Shape::unit() { return Shape(); } + +int& Shape::counter() { + static int count = 0; + return count; +} + +double Circle::radius() const { return area(); } + +double total(const Shape& shape) { return shape.area(); } +} // namespace geo diff --git a/tests/fixtures/cpp_xtu/cpp/src/util.cpp b/tests/fixtures/cpp_xtu/cpp/src/util.cpp new file mode 100644 index 0000000..2f9bc2f --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/util.cpp @@ -0,0 +1,5 @@ +#include "util.h" + +static int twice(int x) { return x * 2; } + +int helper(int x) { return twice(x); } diff --git a/tests/fixtures/cpp_xtu/cpp/src/util.h b/tests/fixtures/cpp_xtu/cpp/src/util.h new file mode 100644 index 0000000..ec4a130 --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/src/util.h @@ -0,0 +1,4 @@ +#pragma once + +int helper(int x); +#define LOG_TWICE(x) ((x) * 2) diff --git a/tests/fixtures/cpp_xtu/cpp/tools/dup.cpp b/tests/fixtures/cpp_xtu/cpp/tools/dup.cpp new file mode 100644 index 0000000..ec09c8b --- /dev/null +++ b/tests/fixtures/cpp_xtu/cpp/tools/dup.cpp @@ -0,0 +1,2 @@ +// Clean negative: a second program's helper that never includes util.h. +int helper(int x) { return x; } diff --git a/tests/fixtures/rust_methods/rust/labels.json b/tests/fixtures/rust_methods/rust/labels.json new file mode 100644 index 0000000..05da47b --- /dev/null +++ b/tests/fixtures/rust_methods/rust/labels.json @@ -0,0 +1,51 @@ +{ + "language_prefixes": {"rust": "src/"}, + "symbols": [ + ["src/lib.rs::run", "function"], + ["src/lib.rs::describe", "function"], + ["src/store.rs::Engine", "class"], + ["src/store.rs::Engine::commit", "function"], + ["src/store.rs::Store", "class"], + ["src/store.rs::Store::open", "function"], + ["src/store.rs::Store::build", "function"], + ["src/store.rs::Store::flush", "function"], + ["src/store.rs::Store::name", "function"], + ["src/store.rs::Store::is_ready", "function"], + ["src/store.rs::Store::log", "function"], + ["src/store.rs::Named", "class"], + ["src/store.rs::Named::label", "function"], + ["src/store.rs::Store::label", "function"], + ["src/store.rs::Engine::label", "function"], + ["src/store.rs::show", "function"], + ["src/store.rs::show_generic", "function"], + ["src/negative.rs::File", "class"], + ["src/negative.rs::File::open", "function"], + ["src/negative.rs::File::sync_all", "function"], + ["src/negative.rs::unrelated", "function"] + ], + "relationships": [ + ["src/lib.rs", "imports", "src/store.rs"], + ["src/lib.rs", "imports", "external:rust:std::fs::File"], + ["src/store.rs", "imports", "external:rust:std::sync::Arc"], + ["src/lib.rs::run", "calls", "src/store.rs::Store::open"], + ["src/lib.rs::run", "calls", "src/store.rs::Store::flush"], + ["src/lib.rs::run", "calls", "src/store.rs::Store::is_ready"], + ["src/lib.rs::run", "calls", "src/lib.rs::describe"], + ["src/lib.rs::run", "calls", "external:rust:File::open"], + ["src/lib.rs::run", "calls", "external:rust:sync_all"], + ["src/lib.rs::run", "calls", "external:rust:Ok"], + ["src/lib.rs::describe", "calls", "src/store.rs::Store::name"], + ["src/store.rs::Store::open", "calls", "external:rust:Ok"], + ["src/store.rs::Store::open", "calls", "src/store.rs::Store::build"], + ["src/store.rs::Store::build", "calls", "external:rust:Arc::new"], + ["src/store.rs::Store::build", "calls", "external:rust:into"], + ["src/store.rs::Store::flush", "calls", "src/store.rs::Engine::commit"], + ["src/store.rs::Store::flush", "calls", "src/store.rs::Store::log"], + ["src/store.rs::Store::label", "calls", "src/store.rs::Store::name"], + ["src/store.rs::Store::label", "calls", "external:rust:to_owned"], + ["src/store.rs::Engine::label", "calls", "external:rust:String::new"], + ["src/store.rs::show", "calls", "src/store.rs::Named::label"], + ["src/store.rs::show_generic", "calls", "external:rust:label"], + ["src/negative.rs::unrelated", "calls", "external:rust:len"] + ] +} diff --git a/tests/fixtures/rust_methods/rust/src/lib.rs b/tests/fixtures/rust_methods/rust/src/lib.rs new file mode 100644 index 0000000..ed07546 --- /dev/null +++ b/tests/fixtures/rust_methods/rust/src/lib.rs @@ -0,0 +1,19 @@ +mod negative; +mod store; + +use crate::store::Store; +use std::fs::File; + +pub fn run(path: &str) -> anyhow::Result<()> { + let store = Store::open(path)?; + store.flush(); + assert!(store.is_ready(), "not ready: {}", describe(&store)); + // Clean negative: std's File must not reach the repository's negative::File. + let file = File::open(path)?; + file.sync_all()?; + Ok(()) +} + +fn describe(store: &Store) -> String { + format!("{}", store.name()) +} diff --git a/tests/fixtures/rust_methods/rust/src/negative.rs b/tests/fixtures/rust_methods/rust/src/negative.rs new file mode 100644 index 0000000..1f256b2 --- /dev/null +++ b/tests/fixtures/rust_methods/rust/src/negative.rs @@ -0,0 +1,14 @@ +// Clean negative: same names as std::fs::File; nothing in lib.rs may call these. +pub struct File; + +impl File { + pub fn open(_path: &str) -> Self { + File + } + + pub fn sync_all(&self) {} +} + +pub fn unrelated(value: &str) -> usize { + value.len() +} diff --git a/tests/fixtures/rust_methods/rust/src/store.rs b/tests/fixtures/rust_methods/rust/src/store.rs new file mode 100644 index 0000000..fee6fc2 --- /dev/null +++ b/tests/fixtures/rust_methods/rust/src/store.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +pub struct Engine; + +impl Engine { + pub fn commit(&self) {} +} + +pub struct Store { + inner: Arc, + name: String, +} + +impl Store { + pub fn open(path: &str) -> anyhow::Result { + Ok(Self::build(path)) + } + + fn build(path: &str) -> Self { + Store { + inner: Arc::new(Engine), + name: path.into(), + } + } + + pub fn flush(&self) { + self.inner.commit(); + self.log(); + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn is_ready(&self) -> bool { + true + } + + fn log(&self) {} +} + +pub trait Named { + fn label(&self) -> String; +} + +impl Named for Store { + fn label(&self) -> String { + self.name().to_owned() + } +} + +impl Named for Engine { + fn label(&self) -> String { + String::new() + } +} + +pub fn show(item: &dyn Named) -> String { + item.label() +} + +// Clean negative: a generic receiver names no concrete implementation. +pub fn show_generic(item: &T) -> String { + item.label() +} diff --git a/tests/rust_methods.rs b/tests/rust_methods.rs new file mode 100644 index 0000000..fdd79a3 --- /dev/null +++ b/tests/rust_methods.rs @@ -0,0 +1,155 @@ +use brick::model::{Building, EdgeKind, Node, NodeKind}; +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() +} + +fn function<'a>(model: &'a Building, path: &str, name: &str) -> &'a Node { + model + .nodes + .iter() + .find(|n| n.kind == NodeKind::Function && n.path.as_deref() == Some(path) && n.name == name) + .unwrap_or_else(|| panic!("missing {path}::{name}")) +} + +/// (target parent name, target name, attribute value) for calls leaving `source`. +fn calls(model: &Building, source: &Node, attribute: &str) -> Vec<(String, String, String)> { + let mut found: Vec<_> = 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(); + let parent = target + .parent + .as_ref() + .and_then(|id| model.nodes.iter().find(|n| &n.id == id)) + .map(|n| n.name.clone()) + .unwrap_or_default(); + let value = e + .attributes + .get(attribute) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + (parent, target.name.clone(), value) + }) + .collect(); + found.sort(); + found +} + +#[test] +fn expression_macro_bodies_yield_calls_and_other_macros_stay_opaque() { + let model = scan_files(&[( + "src/lib.rs", + "fn check() -> bool { true }\nfn name() -> u8 { 0 }\nfn hidden() {}\nmacro_rules! custom { ($e:expr) => { $e } }\nfn run() {\n assert!(check(), \"{}\", format!(\"{}\", name()));\n custom!(hidden());\n}\n", + )]); + let run = function(&model, "src/lib.rs", "run"); + assert_eq!( + calls(&model, run, "source.extraction"), + [ + ("lib.rs".into(), "check".into(), "macro-reparse".into()), + ("lib.rs".into(), "name".into(), "macro-reparse".into()), + ] + ); + let hidden = function(&model, "src/lib.rs", "hidden"); + assert!( + !model + .edges + .iter() + .any(|e| e.target == hidden.id && e.kind == EdgeKind::Calls) + ); +} + +#[test] +fn receivers_resolve_through_self_fields_constructors_and_dyn_traits() { + let model = scan_files(&[( + "src/lib.rs", + "use std::sync::Arc;\npub struct Engine;\nimpl Engine { pub fn commit(&self) {} }\npub trait Named { fn label(&self) -> String; }\npub struct Store { inner: Arc }\nimpl Store {\n pub fn open() -> Result { Ok(Self::build()) }\n fn build() -> Self { Store { inner: Arc::new(Engine) } }\n fn flush(&self) {\n self.inner.commit();\n self.log();\n }\n fn log(&self) {}\n}\nfn run(item: &dyn Named) -> Result<(), ()> {\n let store = Store::open()?;\n store.flush();\n item.label();\n Ok(())\n}\n", + )]); + let resolved = |source: &Node| { + calls(&model, source, "source.resolution") + .into_iter() + .filter(|(parent, _, _)| !parent.is_empty()) + .collect::>() + }; + assert_eq!( + resolved(function(&model, "src/lib.rs", "flush")), + [ + ( + "Engine".into(), + "commit".into(), + "declared-receiver-type".into() + ), + ("Store".into(), "log".into(), "self-receiver".into()), + ] + ); + assert_eq!( + resolved(function(&model, "src/lib.rs", "open")), + [("Store".into(), "build".into(), "impl-type".into())] + ); + assert_eq!( + resolved(function(&model, "src/lib.rs", "run")), + [ + ( + "Named".into(), + "label".into(), + "declared-receiver-type".into() + ), + ( + "Store".into(), + "flush".into(), + "declared-receiver-type".into() + ), + // Same-file `Type::f` already resolves by qualified lookup, unlabeled. + ("Store".into(), "open".into(), "".into()), + ] + ); +} + +#[test] +fn foreign_imports_generics_and_competing_trait_impls_never_guess() { + let model = scan_files(&[ + ( + "src/lib.rs", + "mod local;\nuse std::fs::File;\npub trait Named { fn label(&self) -> String; }\nstruct A;\nstruct B;\nstruct C;\nimpl Named for A { fn label(&self) -> String { String::new() } }\nimpl Named for C { fn label(&self) -> String { String::new() } }\ntrait Other { fn label(&self) -> String; }\nimpl Other for A { fn label(&self) -> String { String::new() } }\nfn run(generic: &T, a: A) {\n let file = File::open(\"x\").unwrap();\n file.sync_all();\n generic.label();\n a.label();\n}\n", + ), + ( + "src/local.rs", + "pub struct File;\nimpl File {\n pub fn open(_p: &str) -> Self { File }\n pub fn sync_all(&self) {}\n}\n", + ), + ]); + let run = function(&model, "src/lib.rs", "run"); + let reasons: Vec<_> = calls(&model, run, "source.unresolved-reason") + .into_iter() + .map(|(_, name, reason)| (name, reason)) + .collect(); + assert_eq!( + reasons, + [ + ("File::open".into(), "qualifier-unknown".into()), + ("label".into(), "ambiguous".into()), + ("label".into(), "receiver-type-unknown".into()), + ("sync_all".into(), "receiver-type-unknown".into()), + ("unwrap".into(), "receiver-type-unknown".into()), + ] + ); + for name in ["open", "sync_all"] { + let local = function(&model, "src/local.rs", name); + assert!( + !model + .edges + .iter() + .any(|e| e.target == local.id && e.kind == EdgeKind::Calls) + ); + } +} -- 2.52.0 From 7c664cb5a0e3a338477ba5b2a96b37831f364cd0 Mon Sep 17 00:00:00 2001 From: trvon Date: Tue, 22 Sep 2026 15:11:42 -0600 Subject: [PATCH 2/2] docs: describe C/C++ and Rust resolution limits and next stages README limits now describe include-closure C++ lookup, declared-receiver member resolution, Rust macro re-parsing, unresolved reasons, and metrics.resolution, plus the remaining adapter gaps. The roadmap records candidate edges, the Fold compiler call lane, and compile_commands/Cargo module resolution as next stages; literature notes cite the call-graph and name-resolution papers found through paperbridge. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 17 ++++++++++++++--- docs/ROADMAP.md | 7 ++++++- docs/research/literature.md | 8 ++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a1393ef..c4cd919 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,20 @@ Relative JS/TS and Dart imports resolve against the importing directory; missing paths never fall back to unrelated basenames. Explicit JS/TS named/default import bindings, module-scoped Rust grouped/aliased imports, and module-qualified Rust public calls can resolve across files. -Member calls and ambiguous targets stay external. Bare package specifiers, -tsconfig aliases, Rust wildcard/block-local binding expansion, and C++ cross-translation-unit -resolution need further adapters. File fan-out remains an observation rather +C++ calls resolve through the include closure: a header prototype pairs with +its definition in another translation unit, lookup walks class (including +in-repository bases) and namespace scopes outward, and `static`/anonymous +definitions stay file-local. C++ and Rust member calls resolve only through an +explicitly declared receiver type (parameter, local, field, `this`/`self`, +smart pointer, or `Self`-returning constructor) and carry +`source.resolution`. Calls inside Rust expression macros (`assert!`, `format!`, +`vec!`, ...) are recovered by re-parsing and carry `source.extraction`. +Every unresolved call edge records `source.unresolved-reason`, and +`metrics.resolution` reports per-language call-site coverage. Ambiguous +targets stay external. Bare package specifiers, tsconfig aliases, Rust +wildcard/block-local binding expansion, Cargo workspace module paths, +`compile_commands.json` include paths, and compiler-grade dispatch need further +adapters. File fan-out remains an observation rather than proof of semantic coupling. Scope architecture gates to production paths and verify candidates against source and tests. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0905562..f331ca5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,12 @@ - Expand and qualify Tree-sitter/compiler frontends per language while keeping them separate evidence backends; do not promote the P4 pilot until macro and multi-project labeled-corpus gates pass. -- Overload/alias/dynamic-dispatch ambiguity represented as candidate edges. +- Overload/alias/dynamic-dispatch ambiguity represented as candidate edges + (schema 5); today ambiguity is an External target with reason `ambiguous`. +- Fold `ProjectCallGraph` and `fold-rust` MIR call export, imported as a + separate Imported call lane with per-file Heuristic/Imported agreement. +- `compile_commands.json` include paths and Cargo workspace/`mod`-tree module + resolution for the heuristic C++/Rust resolvers. - Namespace/package clustering and richer versioned policy rule kinds beyond the seed forbidden-dependency boundary. diff --git a/docs/research/literature.md b/docs/research/literature.md index 8623a0c..96283c1 100644 --- a/docs/research/literature.md +++ b/docs/research/literature.md @@ -29,3 +29,11 @@ The visualization metaphor and the health indicator are consumers of evidence, not replacements for it. No paper above validates Brick's v0.1 weights; those weights remain explicitly experimental. + +## Call resolution (2026-09-22) + +A Paperbridge search on 2026-09-22 (arXiv and OpenAlex) informed the C/C++ +and Rust resolution work: Murphy and Notkin (TOSEM 1998), Grove et al. (OOPSLA +1997, TOPLAS 2001), Srivastava (LOPLAS 1992), Creager and van Antwerpen (stack +graphs, arXiv:2211.01224), Cai and Zhang (OOPSLA 2023), Li et al. (Rupta, +CC 2024), and LocAgent (ACL 2025). -- 2.52.0