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    files
42        .iter()
43        .map(|(path, file)| {
44            let mut dependencies = dependencies_for(path, &file.node, files);
45            let mut edges = file
46                .node
47                .occurrences
48                .iter()
49                .filter(|occurrence| occurrence.role != OccurrenceRole::Definition)
50                .filter_map(|occurrence| resolve_occurrence(path, file, occurrence, files))
51                .collect::<Vec<_>>();
52            edges.sort();
53            edges.dedup();
54            dependencies.extend(
55                edges
56                    .iter()
57                    .filter(|edge| edge.target_path != *path)
58                    .map(|edge| edge.target_path.clone()),
59            );
60            (
61                path.clone(),
62                FileResolution {
63                    dependencies,
64                    edges,
65                },
66            )
67        })
68        .collect()
69}
70
71fn dependencies_for(
72    source_path: &str,
73    source: &SemanticFileNode,
74    files: &BTreeMap<String, RepositorySemanticFile>,
75) -> BTreeSet<String> {
76    source
77        .imports
78        .iter()
79        .filter_map(|import| resolve_module(source_path, source, &import.module_specifier, files))
80        .collect()
81}
82
83fn resolve_occurrence(
84    source_path: &str,
85    source: &RepositorySemanticFile,
86    occurrence: &OccurrenceEntry,
87    files: &BTreeMap<String, RepositorySemanticFile>,
88) -> Option<ResolvedSemanticEdge> {
89    let target = if occurrence.qualifier.is_empty() {
90        definition(files, source_path, &occurrence.name, occurrence.namespace).or_else(|| {
91            if local_definition_shadows(&source.node, occurrence) {
92                None
93            } else {
94                resolve_imported_name(source_path, &source.node, occurrence, files)
95            }
96        })
97    } else {
98        resolve_qualified_name(source_path, &source.node, occurrence, files)
99    }?;
100    Some(ResolvedSemanticEdge {
101        source_occurrence: occurrence.local_id,
102        target_path: target.path,
103        target_file_node: target.file_node,
104        target_definition: target.definition,
105        kind: match occurrence.role {
106            OccurrenceRole::Call => SemanticEdgeKind::Calls,
107            OccurrenceRole::TypeReference => SemanticEdgeKind::TypeRef,
108            OccurrenceRole::Reference => SemanticEdgeKind::RefersTo,
109            OccurrenceRole::Definition => return None,
110        },
111    })
112}
113
114fn resolve_imported_name(
115    source_path: &str,
116    source: &SemanticFileNode,
117    occurrence: &OccurrenceEntry,
118    files: &BTreeMap<String, RepositorySemanticFile>,
119) -> Option<Target> {
120    let candidates = visible_bindings(source, occurrence, &occurrence.name)
121        .filter(|(_, binding)| binding.local == occurrence.name && binding.imported != "*")
122        .filter_map(|(import, binding)| {
123            let path = resolve_module(source_path, source, &import.module_specifier, files)?;
124            definition(files, &path, &binding.imported, occurrence.namespace)
125        })
126        .collect::<BTreeSet<_>>();
127    exactly_one(candidates)
128}
129
130fn resolve_qualified_name(
131    source_path: &str,
132    source: &SemanticFileNode,
133    occurrence: &OccurrenceEntry,
134    files: &BTreeMap<String, RepositorySemanticFile>,
135) -> Option<Target> {
136    let first = occurrence.qualifier.first()?;
137    let imported = visible_bindings(source, occurrence, first)
138        .filter(|(_, binding)| binding.local == *first)
139        .filter(|(_, binding)| binding.imported == "*" || source.language == "rust")
140        .filter_map(|(import, binding)| {
141            let specifier = if source.language == "rust" && binding.imported != "*" {
142                format!("{}::{}", import.module_specifier, binding.imported)
143            } else {
144                import.module_specifier.clone()
145            };
146            resolve_module(source_path, source, &specifier, files)
147        })
148        .collect::<BTreeSet<_>>();
149    let path = exactly_one(imported).or_else(|| {
150        (source.language == "rust" && matches!(first.as_str(), "crate" | "self" | "super"))
151            .then(|| occurrence.qualifier.join("::"))
152            .and_then(|qualifier| resolve_module(source_path, source, &qualifier, files))
153    })?;
154    definition(files, &path, &occurrence.name, occurrence.namespace)
155}
156
157fn visible_bindings<'a>(
158    source: &'a SemanticFileNode,
159    occurrence: &'a OccurrenceEntry,
160    local_name: &'a str,
161) -> impl Iterator<Item = (&'a ImportEntry, &'a ImportBinding)> {
162    let occurrence_namespace = occurrence.namespace;
163    let nearest = source
164        .imports
165        .iter()
166        .filter(|import| {
167            scope::contains(source, import.scope, occurrence.scope)
168                && import.bindings.iter().any(|binding| {
169                    binding.local == local_name
170                        && namespaces_overlap(binding.namespace, occurrence.namespace)
171                })
172        })
173        .map(|import| scope::depth(source, import.scope))
174        .max();
175    source
176        .imports
177        .iter()
178        .filter(move |import| {
179            nearest.is_some_and(|depth| {
180                scope::contains(source, import.scope, occurrence.scope)
181                    && scope::depth(source, import.scope) == depth
182            })
183        })
184        .flat_map(move |import| {
185            import
186                .bindings
187                .iter()
188                .filter(move |binding| namespaces_overlap(binding.namespace, occurrence_namespace))
189                .map(move |binding| (import, binding))
190        })
191}
192
193fn local_definition_shadows(source: &SemanticFileNode, occurrence: &OccurrenceEntry) -> bool {
194    source.occurrences.iter().any(|candidate| {
195        candidate.role == OccurrenceRole::Definition
196            && candidate.name == occurrence.name
197            && namespaces_overlap(candidate.namespace, occurrence.namespace)
198            && candidate.span.start <= occurrence.span.start
199            && scope::contains(source, candidate.scope, occurrence.scope)
200    })
201}
202
203fn resolve_module(
204    source_path: &str,
205    source: &SemanticFileNode,
206    specifier: &str,
207    files: &BTreeMap<String, RepositorySemanticFile>,
208) -> Option<String> {
209    let candidates = match source.language.as_str() {
210        "rust" => rust::module_candidates(source_path, specifier),
211        "typescript" | "javascript" => typescript::module_candidates(source_path, specifier),
212        _ => Vec::new(),
213    };
214    candidates
215        .into_iter()
216        .find(|candidate| files.contains_key(candidate))
217}
218
219#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
220struct Target {
221    path: String,
222    file_node: ContentHash,
223    definition: u32,
224}
225
226fn definition(
227    files: &BTreeMap<String, RepositorySemanticFile>,
228    path: &str,
229    name: &str,
230    namespace: SymbolNamespace,
231) -> Option<Target> {
232    let file = files.get(path)?;
233    let candidates = file
234        .node
235        .symbols
236        .iter()
237        .enumerate()
238        .filter(|(_, symbol)| symbol.container_path.is_empty() && symbol.name == name)
239        .filter(|(_, symbol)| namespaces_overlap(symbol_namespace(symbol.kind), namespace))
240        .map(|(index, _)| Target {
241            path: path.to_string(),
242            file_node: file.node_hash,
243            definition: index as u32,
244        })
245        .collect::<BTreeSet<_>>();
246    exactly_one(candidates)
247}
248
249fn symbol_namespace(kind: SymbolKindTag) -> SymbolNamespace {
250    match kind {
251        SymbolKindTag::Function | SymbolKindTag::Const | SymbolKindTag::Other => {
252            SymbolNamespace::Value
253        }
254        SymbolKindTag::Type
255        | SymbolKindTag::Enum
256        | SymbolKindTag::Trait
257        | SymbolKindTag::Class
258        | SymbolKindTag::Interface
259        | SymbolKindTag::TypeAlias => SymbolNamespace::Type,
260        SymbolKindTag::Module => SymbolNamespace::Both,
261    }
262}
263
264fn namespaces_overlap(a: SymbolNamespace, b: SymbolNamespace) -> bool {
265    a == SymbolNamespace::Both || b == SymbolNamespace::Both || a == b
266}
267
268fn exactly_one<T: Ord>(values: BTreeSet<T>) -> Option<T> {
269    if values.len() == 1 {
270        values.into_iter().next()
271    } else {
272        None
273    }
274}
275
276#[cfg(test)]
277mod tests;