Skip to main content

semantic/cross_file_resolution/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Deterministic repository-level binding over persisted per-file facts.
3
4mod rust;
5mod scope;
6mod typescript;
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use objects::object::{
11    ContentHash, ImportBinding, ImportEntry, OccurrenceEntry, OccurrenceRole, ResolvedSemanticEdge,
12    SemanticEdgeKind, SemanticFileNode, SymbolKindTag, SymbolNamespace,
13};
14
15/// Bump whenever binding policy changes so persisted edges rebuild cleanly.
16pub const RESOLVER_VERSION: u32 = 1;
17
18/// One content-addressed semantic file placed at a repository path.
19#[derive(Clone, Debug)]
20pub struct RepositorySemanticFile {
21    pub node_hash: ContentHash,
22    pub node: SemanticFileNode,
23}
24
25/// Resolution output for one source file.
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct FileResolution {
28    /// Repository files named by resolvable imports from this file.
29    pub dependencies: BTreeSet<String>,
30    /// Successfully bound occurrences. Unresolved occurrences are absent.
31    pub edges: Vec<ResolvedSemanticEdge>,
32}
33
34/// Resolve Rust and TypeScript/JavaScript occurrences across repository files.
35///
36/// Binding is deliberately conservative: ambiguous definitions and imports
37/// remain unresolved rather than selecting a target by iteration order.
38pub fn resolve_repository(
39    files: &BTreeMap<String, RepositorySemanticFile>,
40) -> BTreeMap<String, FileResolution> {
41    resolve_paths(files, files.keys().map(String::as_str))
42}
43
44/// Resolve only `paths`. `files` remains the full repository map so module and
45/// definition lookup can see files outside the invalidation frontier.
46pub fn resolve_paths<'a>(
47    files: &BTreeMap<String, RepositorySemanticFile>,
48    paths: impl IntoIterator<Item = &'a str>,
49) -> BTreeMap<String, FileResolution> {
50    paths
51        .into_iter()
52        .filter_map(|path| {
53            let file = files.get(path)?;
54            Some((path.to_string(), resolve_file(path, file, files)))
55        })
56        .collect()
57}
58
59fn resolve_file(
60    path: &str,
61    file: &RepositorySemanticFile,
62    files: &BTreeMap<String, RepositorySemanticFile>,
63) -> FileResolution {
64    let mut dependencies = dependencies_for(path, &file.node, files);
65    let mut edges = file
66        .node
67        .occurrences
68        .iter()
69        .filter(|occurrence| occurrence.role != OccurrenceRole::Definition)
70        .filter_map(|occurrence| resolve_occurrence(path, file, occurrence, files))
71        .collect::<Vec<_>>();
72    edges.sort();
73    edges.dedup();
74    dependencies.extend(
75        edges
76            .iter()
77            .filter(|edge| edge.target_path != *path)
78            .map(|edge| edge.target_path.clone()),
79    );
80    FileResolution {
81        dependencies,
82        edges,
83    }
84}
85
86fn dependencies_for(
87    source_path: &str,
88    source: &SemanticFileNode,
89    files: &BTreeMap<String, RepositorySemanticFile>,
90) -> BTreeSet<String> {
91    source
92        .imports
93        .iter()
94        .filter_map(|import| resolve_module(source_path, source, &import.module_specifier, files))
95        .collect()
96}
97
98fn resolve_occurrence(
99    source_path: &str,
100    source: &RepositorySemanticFile,
101    occurrence: &OccurrenceEntry,
102    files: &BTreeMap<String, RepositorySemanticFile>,
103) -> Option<ResolvedSemanticEdge> {
104    let target = if occurrence.qualifier.is_empty() {
105        definition(files, source_path, &occurrence.name, occurrence.namespace).or_else(|| {
106            if local_definition_shadows(&source.node, occurrence) {
107                None
108            } else {
109                resolve_imported_name(source_path, &source.node, occurrence, files)
110            }
111        })
112    } else {
113        resolve_qualified_name(source_path, &source.node, occurrence, files)
114    }?;
115    Some(ResolvedSemanticEdge {
116        source_occurrence: occurrence.local_id,
117        target_path: target.path,
118        target_file_node: target.file_node,
119        target_definition: target.definition,
120        kind: match occurrence.role {
121            OccurrenceRole::Call => SemanticEdgeKind::Calls,
122            OccurrenceRole::TypeReference => SemanticEdgeKind::TypeRef,
123            OccurrenceRole::Reference => SemanticEdgeKind::RefersTo,
124            OccurrenceRole::Definition => return None,
125        },
126    })
127}
128
129fn resolve_imported_name(
130    source_path: &str,
131    source: &SemanticFileNode,
132    occurrence: &OccurrenceEntry,
133    files: &BTreeMap<String, RepositorySemanticFile>,
134) -> Option<Target> {
135    let candidates = visible_bindings(source, occurrence, &occurrence.name)
136        .filter(|(_, binding)| binding.local == occurrence.name && binding.imported != "*")
137        .filter_map(|(import, binding)| {
138            let path = resolve_module(source_path, source, &import.module_specifier, files)?;
139            definition(files, &path, &binding.imported, occurrence.namespace)
140        })
141        .collect::<BTreeSet<_>>();
142    exactly_one(candidates)
143}
144
145fn resolve_qualified_name(
146    source_path: &str,
147    source: &SemanticFileNode,
148    occurrence: &OccurrenceEntry,
149    files: &BTreeMap<String, RepositorySemanticFile>,
150) -> Option<Target> {
151    let first = occurrence.qualifier.first()?;
152    let imported = visible_bindings(source, occurrence, first)
153        .filter(|(_, binding)| binding.local == *first)
154        .filter(|(_, binding)| binding.imported == "*" || source.language == "rust")
155        .filter_map(|(import, binding)| {
156            let specifier = if source.language == "rust" && binding.imported != "*" {
157                format!("{}::{}", import.module_specifier, binding.imported)
158            } else {
159                import.module_specifier.clone()
160            };
161            resolve_module(source_path, source, &specifier, files)
162        })
163        .collect::<BTreeSet<_>>();
164    let path = exactly_one(imported).or_else(|| {
165        (source.language == "rust" && matches!(first.as_str(), "crate" | "self" | "super"))
166            .then(|| occurrence.qualifier.join("::"))
167            .and_then(|qualifier| resolve_module(source_path, source, &qualifier, files))
168    })?;
169    definition(files, &path, &occurrence.name, occurrence.namespace)
170}
171
172fn visible_bindings<'a>(
173    source: &'a SemanticFileNode,
174    occurrence: &'a OccurrenceEntry,
175    local_name: &'a str,
176) -> impl Iterator<Item = (&'a ImportEntry, &'a ImportBinding)> {
177    let occurrence_namespace = occurrence.namespace;
178    let nearest = source
179        .imports
180        .iter()
181        .filter(|import| {
182            scope::contains(source, import.scope, occurrence.scope)
183                && import.bindings.iter().any(|binding| {
184                    binding.local == local_name
185                        && namespaces_overlap(binding.namespace, occurrence.namespace)
186                })
187        })
188        .map(|import| scope::depth(source, import.scope))
189        .max();
190    source
191        .imports
192        .iter()
193        .filter(move |import| {
194            nearest.is_some_and(|depth| {
195                scope::contains(source, import.scope, occurrence.scope)
196                    && scope::depth(source, import.scope) == depth
197            })
198        })
199        .flat_map(move |import| {
200            import
201                .bindings
202                .iter()
203                .filter(move |binding| namespaces_overlap(binding.namespace, occurrence_namespace))
204                .map(move |binding| (import, binding))
205        })
206}
207
208fn local_definition_shadows(source: &SemanticFileNode, occurrence: &OccurrenceEntry) -> bool {
209    source.occurrences.iter().any(|candidate| {
210        candidate.role == OccurrenceRole::Definition
211            && candidate.name == occurrence.name
212            && namespaces_overlap(candidate.namespace, occurrence.namespace)
213            && candidate.span.start <= occurrence.span.start
214            && scope::contains(source, candidate.scope, occurrence.scope)
215    })
216}
217
218fn resolve_module(
219    source_path: &str,
220    source: &SemanticFileNode,
221    specifier: &str,
222    files: &BTreeMap<String, RepositorySemanticFile>,
223) -> Option<String> {
224    let candidates = match source.language.as_str() {
225        "rust" => rust::module_candidates(source_path, specifier),
226        "typescript" | "javascript" => typescript::module_candidates(source_path, specifier),
227        _ => Vec::new(),
228    };
229    candidates
230        .into_iter()
231        .find(|candidate| files.contains_key(candidate))
232}
233
234#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
235struct Target {
236    path: String,
237    file_node: ContentHash,
238    definition: u32,
239}
240
241fn definition(
242    files: &BTreeMap<String, RepositorySemanticFile>,
243    path: &str,
244    name: &str,
245    namespace: SymbolNamespace,
246) -> Option<Target> {
247    let file = files.get(path)?;
248    let candidates = file
249        .node
250        .symbols
251        .iter()
252        .enumerate()
253        .filter(|(_, symbol)| symbol.container_path.is_empty() && symbol.name == name)
254        .filter(|(_, symbol)| namespaces_overlap(symbol_namespace(symbol.kind), namespace))
255        .map(|(index, _)| Target {
256            path: path.to_string(),
257            file_node: file.node_hash,
258            definition: index as u32,
259        })
260        .collect::<BTreeSet<_>>();
261    exactly_one(candidates)
262}
263
264fn symbol_namespace(kind: SymbolKindTag) -> SymbolNamespace {
265    match kind {
266        SymbolKindTag::Function | SymbolKindTag::Const | SymbolKindTag::Other => {
267            SymbolNamespace::Value
268        }
269        SymbolKindTag::Type
270        | SymbolKindTag::Enum
271        | SymbolKindTag::Trait
272        | SymbolKindTag::Class
273        | SymbolKindTag::Interface
274        | SymbolKindTag::TypeAlias => SymbolNamespace::Type,
275        SymbolKindTag::Module => SymbolNamespace::Both,
276    }
277}
278
279fn namespaces_overlap(a: SymbolNamespace, b: SymbolNamespace) -> bool {
280    a == SymbolNamespace::Both || b == SymbolNamespace::Both || a == b
281}
282
283fn exactly_one<T: Ord>(values: BTreeSet<T>) -> Option<T> {
284    if values.len() == 1 {
285        values.into_iter().next()
286    } else {
287        None
288    }
289}
290
291#[cfg(test)]
292mod tests;