Skip to main content

codemem_engine/index/
resolver.rs

1//! Reference resolution into graph edges.
2//!
3//! Resolves unresolved references (by simple name) to their target symbols
4//! and produces typed edges for the knowledge graph.
5
6use crate::index::symbol::{Reference, ReferenceKind, Symbol};
7use codemem_core::RelationshipType;
8use std::collections::{HashMap, HashSet};
9
10/// A resolved edge connecting two symbols by qualified name.
11#[derive(Debug, Clone)]
12pub struct ResolvedEdge {
13    /// Qualified name of the source symbol.
14    pub source_qualified_name: String,
15    /// Qualified name of the resolved target symbol.
16    pub target_qualified_name: String,
17    /// The relationship type for this edge.
18    pub relationship: RelationshipType,
19    /// File path where the reference occurs.
20    pub file_path: String,
21    /// Line number of the reference.
22    pub line: usize,
23    /// R2: Confidence of the resolution (0.0 = guessed, 1.0 = exact match).
24    pub resolution_confidence: f64,
25}
26
27/// Resolves references to target symbols and produces graph edges.
28pub struct ReferenceResolver {
29    /// Map of qualified_name -> Symbol for exact resolution.
30    symbol_index: HashMap<String, Symbol>,
31    /// Map of simple name -> Vec<qualified_name> for ambiguous resolution.
32    name_index: HashMap<String, Vec<String>>,
33    /// R2: Set of imported qualified names per file for scoring.
34    file_imports: HashMap<String, HashSet<String>>,
35}
36
37impl ReferenceResolver {
38    /// Create a new empty resolver.
39    pub fn new() -> Self {
40        Self {
41            symbol_index: HashMap::new(),
42            name_index: HashMap::new(),
43            file_imports: HashMap::new(),
44        }
45    }
46
47    /// Add symbols to the resolver's index.
48    pub fn add_symbols(&mut self, symbols: &[Symbol]) {
49        for sym in symbols {
50            self.symbol_index
51                .insert(sym.qualified_name.clone(), sym.clone());
52
53            self.name_index
54                .entry(sym.name.clone())
55                .or_default()
56                .push(sym.qualified_name.clone());
57        }
58    }
59
60    /// R2: Register import references so the resolver can prefer imported symbols.
61    pub fn add_imports(&mut self, references: &[Reference]) {
62        for r in references {
63            if r.kind == ReferenceKind::Import {
64                self.file_imports
65                    .entry(r.file_path.clone())
66                    .or_default()
67                    .insert(r.target_name.clone());
68            }
69        }
70    }
71
72    /// Resolve a single reference to a target symbol with confidence.
73    ///
74    /// Resolution strategy:
75    /// 1. Exact match on qualified name (confidence 1.0)
76    /// 2. R4: Cross-module path resolution — strip `crate::` prefix, try partial matches
77    /// 3. Simple name match with R2 scoring heuristics (confidence varies)
78    /// 4. Unresolved (returns None)
79    pub fn resolve_with_confidence(&self, reference: &Reference) -> Option<(&Symbol, f64)> {
80        // 1. Exact qualified name match
81        if let Some(sym) = self.symbol_index.get(&reference.target_name) {
82            return Some((sym, 1.0));
83        }
84
85        // R4: Try stripping `crate::` prefix for cross-module resolution
86        if reference.target_name.starts_with("crate::") {
87            let stripped = &reference.target_name["crate::".len()..];
88            if let Some(sym) = self.symbol_index.get(stripped) {
89                return Some((sym, 0.95));
90            }
91            // Try matching against all qualified names ending with this suffix
92            for (qn, sym) in &self.symbol_index {
93                if qn.ends_with(stripped) {
94                    let prefix_len = qn.len() - stripped.len();
95                    if prefix_len == 0 || qn.as_bytes()[prefix_len - 1] == b':' {
96                        return Some((sym, 0.85));
97                    }
98                }
99            }
100        }
101
102        // R4: Try matching `module::function` against `crate::module::function`
103        if reference.target_name.contains("::") {
104            let with_crate = format!("crate::{}", reference.target_name);
105            if let Some(sym) = self.symbol_index.get(&with_crate) {
106                return Some((sym, 0.9));
107            }
108            // Try suffix matching for partial paths
109            for (qn, sym) in &self.symbol_index {
110                if qn.ends_with(&reference.target_name) {
111                    let prefix_len = qn.len() - reference.target_name.len();
112                    if prefix_len == 0 || qn.as_bytes()[prefix_len - 1] == b':' {
113                        return Some((sym, 0.8));
114                    }
115                }
116            }
117        }
118
119        // 2. Simple name match with scoring heuristics
120        let simple_name = reference
121            .target_name
122            .rsplit("::")
123            .next()
124            .unwrap_or(&reference.target_name);
125
126        if let Some(candidates) = self.name_index.get(simple_name) {
127            if candidates.len() == 1 {
128                // Unambiguous
129                let confidence = if simple_name == reference.target_name {
130                    0.9 // Exact simple name match
131                } else {
132                    0.7 // Matched via last segment only
133                };
134                return self
135                    .symbol_index
136                    .get(&candidates[0])
137                    .map(|s| (s, confidence));
138            }
139
140            // R2: Score candidates with heuristics
141            let file_imports = self.file_imports.get(&reference.file_path);
142            let mut best: Option<(&Symbol, f64)> = None;
143
144            for qn in candidates {
145                if let Some(sym) = self.symbol_index.get(qn) {
146                    let mut score: f64 = 0.0;
147
148                    // Prefer symbols imported in the same file
149                    if let Some(imports) = file_imports {
150                        if imports.contains(&sym.qualified_name)
151                            || imports.iter().any(|imp| imp.ends_with(&sym.name))
152                        {
153                            score += 0.4;
154                        }
155                    }
156
157                    // Prefer symbols in the same file
158                    if sym.file_path == reference.file_path {
159                        score += 0.3;
160                    }
161
162                    // Prefer exact name match (not just substring/last-segment)
163                    if sym.name == reference.target_name {
164                        score += 0.2;
165                    }
166
167                    // Prefer symbols in the same package/module (share path prefix)
168                    let ref_module = extract_module_path(&reference.file_path);
169                    let sym_module = extract_module_path(&sym.file_path);
170                    if ref_module == sym_module {
171                        score += 0.1;
172                    }
173
174                    if best.is_none() || score > best.unwrap().1 {
175                        best = Some((sym, score));
176                    }
177                }
178            }
179
180            if let Some((sym, score)) = best {
181                // Normalize score to a confidence value in [0.3, 0.8]
182                let confidence = 0.3 + (score.min(1.0) * 0.5);
183                return Some((sym, confidence));
184            }
185        }
186
187        None
188    }
189
190    /// Resolve all references into edges.
191    ///
192    /// Only produces edges for successfully resolved references.
193    pub fn resolve_all(&self, references: &[Reference]) -> Vec<ResolvedEdge> {
194        references
195            .iter()
196            .filter_map(|r| {
197                let (target, confidence) = self.resolve_with_confidence(r)?;
198                let relationship = match r.kind {
199                    ReferenceKind::Call => RelationshipType::Calls,
200                    ReferenceKind::Import => RelationshipType::Imports,
201                    ReferenceKind::Inherits => RelationshipType::Inherits,
202                    ReferenceKind::Implements => RelationshipType::Implements,
203                    ReferenceKind::TypeUsage => RelationshipType::DependsOn,
204                };
205
206                Some(ResolvedEdge {
207                    source_qualified_name: r.source_qualified_name.clone(),
208                    target_qualified_name: target.qualified_name.clone(),
209                    relationship,
210                    file_path: r.file_path.clone(),
211                    line: r.line,
212                    resolution_confidence: confidence,
213                })
214            })
215            .collect()
216    }
217}
218
219/// Extract a module path from a file path for same-package heuristic.
220/// e.g., "src/index/parser.rs" -> "src/index"
221fn extract_module_path(file_path: &str) -> &str {
222    file_path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
223}
224
225impl Default for ReferenceResolver {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231#[cfg(test)]
232#[path = "tests/resolver_tests.rs"]
233mod tests;