Skip to main content

meta_ast/graph/
resolver.rs

1//! Reference resolution via FlattenedScopeCache.
2//!
3//! Pre-computes the visible scope per file by BFS-ing the import graph
4//! once, then resolves references with O(1) lookups instead of
5//! per-reference graph traversals.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::PathBuf;
9
10use rayon::prelude::*;
11
12use crate::error::{Diagnostic, Severity};
13use crate::graph::edge::{
14    CONFIDENCE_CROSS_LANGUAGE, CONFIDENCE_OWN_OR_DIRECT, CONFIDENCE_TRANSITIVE,
15};
16use crate::language::LangId;
17use crate::model::{FileExtraction, FileId, SymbolId, Visibility};
18
19pub type ScopeMap = HashMap<String, Vec<(SymbolId, f32)>>;
20pub(crate) type SymbolIndexEntry = (SymbolId, String, LangId, Option<Visibility>);
21pub(crate) type SymbolIndex = HashMap<FileId, Vec<SymbolIndexEntry>>;
22
23/// Bundles the data needed for scope resolution across files.
24pub struct ResolutionContext {
25    pub symbol_index: SymbolIndex,
26    pub import_adjacency: HashMap<FileId, Vec<FileId>>,
27    pub file_languages: HashMap<FileId, LangId>,
28    pub file_paths: HashMap<FileId, PathBuf>,
29}
30
31impl ResolutionContext {
32    /// Build a ResolutionContext from extraction results and graph data.
33    pub fn from_extractions<F>(
34        extractions: &[F],
35        path_to_file_id: &HashMap<PathBuf, FileId>,
36        import_adjacency: HashMap<FileId, Vec<FileId>>,
37    ) -> Self
38    where
39        F: std::borrow::Borrow<FileExtraction>,
40    {
41        let symbol_index = build_symbol_index(extractions, path_to_file_id);
42        let file_languages: HashMap<_, _> = extractions
43            .iter()
44            .filter_map(|f| {
45                let f = f.borrow();
46                Some((path_to_file_id.get(&f.path)?.to_owned(), f.lang))
47            })
48            .collect();
49        let file_paths: HashMap<_, _> = path_to_file_id
50            .iter()
51            .map(|(path, &fid)| (fid, path.clone()))
52            .collect();
53
54        Self {
55            symbol_index,
56            import_adjacency,
57            file_languages,
58            file_paths,
59        }
60    }
61}
62
63/// Pre-computed visible scope for each file.
64///
65/// Scope = own symbols + public symbols from imported files transitively.
66/// Local symbols take priority over imported (shadowing).
67#[derive(Debug, Clone)]
68pub struct FlattenedScopeCache {
69    scopes: HashMap<FileId, ScopeMap>,
70}
71
72impl FlattenedScopeCache {
73    /// Build the scope cache from the file->symbols index and import adjacency.
74    ///
75    /// For each file, BFS over import edges, collecting public symbols from
76    /// reachable files. Confidence decays with distance:
77    /// - 1.0: own file or direct import, same language
78    /// - 0.8: transitive import, same language
79    /// - 0.6: cross-language imports
80    pub fn build(ctx: &ResolutionContext, diagnostics: &mut Vec<Diagnostic>) -> Self {
81        let results: Vec<(FileId, ScopeMap, Vec<Diagnostic>)> = ctx
82            .symbol_index
83            .par_iter()
84            .map(|(&file_id, _)| {
85                let (scope, diags) = Self::compute_scope(file_id, ctx);
86                (file_id, scope, diags)
87            })
88            .collect();
89
90        let mut scopes = HashMap::with_capacity(results.len());
91        for (file_id, scope, diags) in results {
92            scopes.insert(file_id, scope);
93            diagnostics.extend(diags);
94        }
95
96        Self { scopes }
97    }
98
99    fn compute_scope(file_id: FileId, ctx: &ResolutionContext) -> (ScopeMap, Vec<Diagnostic>) {
100        let mut diagnostics = Vec::new();
101        let source_lang = ctx.file_languages.get(&file_id).copied();
102        let mut scope: ScopeMap = HashMap::new();
103        let mut visited: HashSet<FileId> = HashSet::new();
104        let mut queue: VecDeque<(FileId, usize)> = VecDeque::new();
105
106        queue.push_back((file_id, 0));
107
108        while let Some((current, distance)) = queue.pop_front() {
109            if !visited.insert(current) {
110                continue;
111            }
112
113            if let Some(symbols) = ctx.symbol_index.get(&current) {
114                let default_vis = ctx
115                    .file_languages
116                    .get(&current)
117                    .map(|lang| lang.spec().default_visibility)
118                    .unwrap_or(crate::language::DefaultVisibility::PublicByDefault);
119
120                for (sym_id, name, sym_lang, visibility) in symbols {
121                    let is_public = match visibility {
122                        Some(Visibility::Public) => true,
123                        Some(Visibility::Private) => current == file_id,
124                        None => {
125                            matches!(
126                                default_vis,
127                                crate::language::DefaultVisibility::PublicByDefault
128                            ) || current == file_id
129                        }
130                    };
131
132                    if !is_public {
133                        continue;
134                    }
135
136                    let same_lang = source_lang.is_some() && source_lang == Some(*sym_lang);
137                    let diff_lang = source_lang.is_some() && source_lang != Some(*sym_lang);
138
139                    let confidence = if distance == 0 || (distance == 1 && same_lang) {
140                        CONFIDENCE_OWN_OR_DIRECT
141                    } else if diff_lang {
142                        CONFIDENCE_CROSS_LANGUAGE
143                    } else {
144                        CONFIDENCE_TRANSITIVE
145                    };
146
147                    if let Some(entries) = scope.get_mut(name) {
148                        entries.push((*sym_id, confidence));
149                    } else {
150                        scope.insert(name.clone(), vec![(*sym_id, confidence)]);
151                    }
152                }
153            }
154
155            if let Some(neighbors) = ctx.import_adjacency.get(&current) {
156                for &neighbor in neighbors {
157                    if !visited.contains(&neighbor) {
158                        queue.push_back((neighbor, distance + 1));
159                    } else if neighbor == file_id {
160                        let path = ctx
161                            .file_paths
162                            .get(&current)
163                            .cloned()
164                            .unwrap_or_else(|| PathBuf::from("<unknown>"));
165                        let root_path = ctx
166                            .file_paths
167                            .get(&file_id)
168                            .map(|p| p.display().to_string())
169                            .unwrap_or_else(|| "<unknown>".to_string());
170                        diagnostics.push(Diagnostic {
171                            path,
172                            severity: Severity::Warning,
173                            message: format!(
174                                "circular import: {} -> {}",
175                                current.to_raw(),
176                                root_path
177                            ),
178                            source_range: None,
179                        });
180                    }
181                }
182            }
183        }
184
185        // Sort each entry: higher confidence first, then by symbol_id (stable)
186        for entries in scope.values_mut() {
187            entries.sort_by(|a, b| {
188                b.1.partial_cmp(&a.1)
189                    .unwrap_or(std::cmp::Ordering::Equal)
190                    .then(a.0.to_raw().cmp(&b.0.to_raw()))
191            });
192        }
193
194        (scope, diagnostics)
195    }
196
197    /// Look up a name in a file's flattened scope.
198    ///
199    /// Returns matching symbols with confidence scores, or None if not found.
200    pub fn resolve(&self, file_id: FileId, name: &str) -> Option<&[(SymbolId, f32)]> {
201        self.scopes
202            .get(&file_id)
203            .and_then(|s| s.get(name).map(|v| v.as_slice()))
204    }
205
206    /// Full scope for a file: name to candidates with confidence.
207    pub fn scope(&self, file_id: FileId) -> Option<&ScopeMap> {
208        self.scopes.get(&file_id)
209    }
210
211    /// Iterate over every file scope.
212    pub fn iter_scopes(&self) -> impl Iterator<Item = (FileId, &ScopeMap)> {
213        self.scopes.iter().map(|(&file_id, scope)| (file_id, scope))
214    }
215
216    /// Returns the number of scopes in the cache.
217    pub fn len(&self) -> usize {
218        self.scopes.len()
219    }
220
221    /// Returns true if the cache is empty.
222    pub fn is_empty(&self) -> bool {
223        self.scopes.is_empty()
224    }
225}
226
227/// Resolve all references across extracted files.
228///
229/// Returns a list of (source_symbol_id, target_symbol_id, confidence) triples
230/// representing ReferenceEdges to add. Confidence is threaded from the
231/// FlattenedScopeCache (1.0 local/direct, 0.8 transitive, 0.6 cross-language).
232/// Warnings for unresolved references are appended to `diagnostics`.
233pub fn resolve_all_references<F>(
234    extractions: &[F],
235    path_to_file_id: &HashMap<PathBuf, FileId>,
236    scope_cache: &FlattenedScopeCache,
237    diagnostics: &mut Vec<Diagnostic>,
238) -> Vec<(SymbolId, SymbolId, f32)>
239where
240    F: std::borrow::Borrow<FileExtraction> + Sync,
241{
242    #[allow(clippy::type_complexity)]
243    let results: Vec<(Vec<(SymbolId, SymbolId, f32)>, Vec<Diagnostic>)> = extractions
244        .par_iter()
245        .map(|file_ext| {
246            let file_ext = file_ext.borrow();
247            let mut local_edges = Vec::new();
248            let mut local_diags = Vec::new();
249
250            let file_id = match path_to_file_id.get(&file_ext.path) {
251                Some(&id) => id,
252                None => return (local_edges, local_diags),
253            };
254
255            let file_path = &file_ext.path;
256            for ref_ in &file_ext.references {
257                if let Some(matches) = scope_cache.resolve(file_id, &ref_.name) {
258                    // Find the innermost source symbol that contains this reference range
259                    // Pick the symbol with the smallest byte span length
260                    let source_sym = file_ext
261                        .symbols
262                        .iter()
263                        .filter(|s| {
264                            s.source_range.byte_start <= ref_.range.byte_start
265                                && s.source_range.byte_end >= ref_.range.byte_end
266                        })
267                        .min_by_key(|s| s.source_range.byte_end - s.source_range.byte_start);
268
269                    if let Some(source) = source_sym {
270                        for &(target_id, confidence) in matches {
271                            // Don't add self-references (symbol to itself)
272                            if source.id != target_id {
273                                local_edges.push((source.id, target_id, confidence));
274                            }
275                        }
276                    }
277                } else {
278                    local_diags.push(Diagnostic {
279                        path: file_path.clone(),
280                        severity: Severity::Warning,
281                        message: format!("unresolved reference: '{}'", ref_.name),
282                        source_range: Some(ref_.range.clone()),
283                    });
284                }
285            }
286            (local_edges, local_diags)
287        })
288        .collect();
289
290    let mut edges = Vec::new();
291    for (local_edges, local_diags) in results {
292        edges.extend(local_edges);
293        diagnostics.extend(local_diags);
294    }
295
296    // Deduplicate: max-merge confidence for same (src, dst) pairs
297    let mut seen: HashMap<(SymbolId, SymbolId), f32> = HashMap::with_capacity(edges.len());
298    for (src, dst, conf) in edges {
299        seen.entry((src, dst))
300            .and_modify(|e| *e = e.max(conf))
301            .or_insert(conf);
302    }
303    let mut deduped: Vec<_> = seen
304        .into_iter()
305        .map(|((src, dst), conf)| (src, dst, conf))
306        .collect();
307    deduped.sort_by_key(|(a, b, _)| (a.to_raw(), b.to_raw()));
308    deduped
309}
310
311/// Build a symbol index from extracted files and a path-to-FileId mapping.
312///
313/// Returns: SymbolIndex
314pub fn build_symbol_index<F>(
315    extractions: &[F],
316    path_to_file_id: &HashMap<PathBuf, FileId>,
317) -> SymbolIndex
318where
319    F: std::borrow::Borrow<FileExtraction>,
320{
321    let mut index: SymbolIndex = HashMap::new();
322
323    for file_ext in extractions {
324        let file_ext = file_ext.borrow();
325        if let Some(&file_id) = path_to_file_id.get(&file_ext.path) {
326            let entries: Vec<_> = file_ext
327                .symbols
328                .iter()
329                .map(|s| (s.id, s.name.clone(), s.language, s.visibility))
330                .collect();
331            index.entry(file_id).or_default().extend(entries);
332        }
333    }
334
335    index
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::path::PathBuf;
342
343    #[test]
344    fn empty_cache() {
345        let cache = FlattenedScopeCache {
346            scopes: HashMap::new(),
347        };
348        assert!(cache.is_empty());
349        assert_eq!(cache.len(), 0);
350        assert!(cache.resolve(FileId::new(1).unwrap(), "foo").is_none());
351    }
352
353    #[test]
354    fn scope_cache_resolve_own_file() {
355        let mut symbol_index: SymbolIndex = HashMap::new();
356        symbol_index.insert(
357            FileId::new(1).unwrap(),
358            vec![(
359                SymbolId::new(10).unwrap(),
360                "main".into(),
361                LangId::Python,
362                None,
363            )],
364        );
365
366        let ctx = ResolutionContext {
367            symbol_index,
368            import_adjacency: HashMap::new(),
369            file_languages: HashMap::from([(FileId::new(1).unwrap(), LangId::Python)]),
370            file_paths: HashMap::new(),
371        };
372
373        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
374        let result = cache.resolve(FileId::new(1).unwrap(), "main");
375        assert!(result.is_some());
376        let matches = result.unwrap();
377        assert_eq!(matches.len(), 1);
378        assert_eq!(matches[0].0, SymbolId::new(10).unwrap());
379        assert_eq!(matches[0].1, 1.0);
380    }
381
382    #[test]
383    fn scope_cache_resolve_imported_symbol() {
384        let mut symbol_index = HashMap::new();
385        symbol_index.insert(FileId::new(1).unwrap(), vec![]);
386        symbol_index.insert(
387            FileId::new(2).unwrap(),
388            vec![(
389                SymbolId::new(20).unwrap(),
390                "helper".into(),
391                LangId::Python,
392                Some(Visibility::Public),
393            )],
394        );
395
396        let ctx = ResolutionContext {
397            symbol_index,
398            import_adjacency: HashMap::from([(
399                FileId::new(1).unwrap(),
400                vec![FileId::new(2).unwrap()],
401            )]),
402            file_languages: HashMap::from([
403                (FileId::new(1).unwrap(), LangId::Python),
404                (FileId::new(2).unwrap(), LangId::Python),
405            ]),
406            file_paths: HashMap::new(),
407        };
408
409        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
410        let result = cache.resolve(FileId::new(1).unwrap(), "helper");
411        assert!(result.is_some());
412        let matches = result.unwrap();
413        assert_eq!(matches.len(), 1);
414        assert_eq!(matches[0].0, SymbolId::new(20).unwrap());
415        assert_eq!(matches[0].1, 1.0);
416    }
417
418    #[test]
419    fn scope_cache_missing_symbol() {
420        let mut symbol_index = HashMap::new();
421        symbol_index.insert(
422            FileId::new(1).unwrap(),
423            vec![(
424                SymbolId::new(10).unwrap(),
425                "foo".into(),
426                LangId::Python,
427                None,
428            )],
429        );
430
431        let ctx = ResolutionContext {
432            symbol_index,
433            import_adjacency: HashMap::new(),
434            file_languages: HashMap::from([(FileId::new(1).unwrap(), LangId::Python)]),
435            file_paths: HashMap::new(),
436        };
437
438        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
439        assert!(cache.resolve(FileId::new(1).unwrap(), "bar").is_none());
440    }
441
442    #[test]
443    fn scope_cache_cycle_safe() {
444        let mut symbol_index = HashMap::new();
445        symbol_index.insert(
446            FileId::new(1).unwrap(),
447            vec![(
448                SymbolId::new(10).unwrap(),
449                "a".into(),
450                LangId::Python,
451                Some(Visibility::Public),
452            )],
453        );
454        symbol_index.insert(
455            FileId::new(2).unwrap(),
456            vec![(
457                SymbolId::new(20).unwrap(),
458                "b".into(),
459                LangId::Python,
460                Some(Visibility::Public),
461            )],
462        );
463
464        // Cycle: 0 -> 1 -> 0
465        let ctx = ResolutionContext {
466            symbol_index,
467            import_adjacency: HashMap::from([
468                (FileId::new(1).unwrap(), vec![FileId::new(2).unwrap()]),
469                (FileId::new(2).unwrap(), vec![FileId::new(1).unwrap()]),
470            ]),
471            file_languages: HashMap::from([
472                (FileId::new(1).unwrap(), LangId::Python),
473                (FileId::new(2).unwrap(), LangId::Python),
474            ]),
475            file_paths: HashMap::new(),
476        };
477
478        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
479        // Should not infinite loop
480        assert!(cache.resolve(FileId::new(1).unwrap(), "b").is_some());
481        assert!(cache.resolve(FileId::new(2).unwrap(), "a").is_some());
482    }
483
484    #[test]
485    fn scope_cache_cross_language_confidence() {
486        let mut symbol_index = HashMap::new();
487        symbol_index.insert(FileId::new(1).unwrap(), vec![]);
488        symbol_index.insert(
489            FileId::new(2).unwrap(),
490            vec![(
491                SymbolId::new(20).unwrap(),
492                "util".into(),
493                LangId::Rust,
494                Some(Visibility::Public),
495            )],
496        );
497
498        let ctx = ResolutionContext {
499            symbol_index,
500            import_adjacency: HashMap::from([(
501                FileId::new(1).unwrap(),
502                vec![FileId::new(2).unwrap()],
503            )]),
504            file_languages: HashMap::from([
505                (FileId::new(1).unwrap(), LangId::Python),
506                (FileId::new(2).unwrap(), LangId::Rust),
507            ]),
508            file_paths: HashMap::new(),
509        };
510
511        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
512        let result = cache.resolve(FileId::new(1).unwrap(), "util");
513        assert!(result.is_some());
514        assert_eq!(result.unwrap()[0].1, 0.6);
515    }
516
517    #[test]
518    fn resolve_references_creates_edges() {
519        use crate::model::{LineColumn, SourceRange, Symbol, SymbolKind, UnresolvedReference};
520
521        let sym_a = Symbol {
522            id: SymbolId::new(1).unwrap(),
523            name: "caller".into(),
524            kind: SymbolKind::Function,
525            language: LangId::Python,
526            file_path: PathBuf::from("/proj/a.py"),
527            source_range: SourceRange {
528                byte_start: 0,
529                byte_end: 50,
530                start: LineColumn { line: 0, column: 0 },
531                end: LineColumn { line: 2, column: 0 },
532            },
533            visibility: None,
534            signature: None,
535            docstring: None,
536            is_async: false,
537        };
538
539        let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
540        file.symbols = vec![sym_a];
541        file.references = vec![UnresolvedReference {
542            name: "helper".into(),
543            range: SourceRange {
544                byte_start: 20,
545                byte_end: 26,
546                start: LineColumn { line: 1, column: 4 },
547                end: LineColumn {
548                    line: 1,
549                    column: 10,
550                },
551            },
552        }];
553
554        let mut path_to_file_id = HashMap::new();
555        path_to_file_id.insert(PathBuf::from("/proj/a.py"), FileId::new(1).unwrap());
556
557        let mut scopes: HashMap<FileId, ScopeMap> = HashMap::new();
558        let mut scope = HashMap::new();
559        scope.insert("helper".into(), vec![(SymbolId::new(99).unwrap(), 1.0)]);
560        scopes.insert(FileId::new(1).unwrap(), scope);
561
562        let cache = FlattenedScopeCache { scopes };
563
564        let edges = resolve_all_references(&[file], &path_to_file_id, &cache, &mut Vec::new());
565        assert_eq!(edges.len(), 1);
566        assert_eq!(edges[0].0, SymbolId::new(1).unwrap());
567        assert_eq!(edges[0].1, SymbolId::new(99).unwrap());
568        assert_eq!(edges[0].2, 1.0);
569    }
570
571    #[test]
572    fn resolve_references_selects_innermost_enclosing_symbol_regardless_of_vector_order() {
573        use crate::model::{LineColumn, SourceRange, Symbol, SymbolKind, UnresolvedReference};
574
575        // Inner method (span 40: 10..50)
576        let inner_method = Symbol {
577            id: SymbolId::new(1).unwrap(),
578            name: "inner_method".into(),
579            kind: SymbolKind::Method,
580            language: LangId::Python,
581            file_path: PathBuf::from("/proj/a.py"),
582            source_range: SourceRange {
583                byte_start: 10,
584                byte_end: 50,
585                start: LineColumn { line: 1, column: 0 },
586                end: LineColumn { line: 3, column: 0 },
587            },
588            visibility: None,
589            signature: None,
590            docstring: None,
591            is_async: false,
592        };
593
594        // Outer class (span 100: 0..100) placed AFTER inner_method in vector
595        let outer_class = Symbol {
596            id: SymbolId::new(2).unwrap(),
597            name: "OuterClass".into(),
598            kind: SymbolKind::Class,
599            language: LangId::Python,
600            file_path: PathBuf::from("/proj/a.py"),
601            source_range: SourceRange {
602                byte_start: 0,
603                byte_end: 100,
604                start: LineColumn { line: 0, column: 0 },
605                end: LineColumn { line: 5, column: 0 },
606            },
607            visibility: None,
608            signature: None,
609            docstring: None,
610            is_async: false,
611        };
612
613        let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
614        file.symbols = vec![inner_method, outer_class]; // Order: inner first, outer second
615        file.references = vec![UnresolvedReference {
616            name: "helper".into(),
617            range: SourceRange {
618                byte_start: 20,
619                byte_end: 26,
620                start: LineColumn { line: 2, column: 4 },
621                end: LineColumn {
622                    line: 2,
623                    column: 10,
624                },
625            },
626        }];
627
628        let mut path_to_file_id = HashMap::new();
629        path_to_file_id.insert(PathBuf::from("/proj/a.py"), FileId::new(1).unwrap());
630
631        let mut scopes: HashMap<FileId, ScopeMap> = HashMap::new();
632        let mut scope = HashMap::new();
633        scope.insert("helper".into(), vec![(SymbolId::new(99).unwrap(), 1.0)]);
634        scopes.insert(FileId::new(1).unwrap(), scope);
635
636        let cache = FlattenedScopeCache { scopes };
637
638        let edges = resolve_all_references(&[file], &path_to_file_id, &cache, &mut Vec::new());
639        assert_eq!(edges.len(), 1);
640        // Must resolve from SymbolId(1) (inner_method), NOT SymbolId(2) (outer_class)
641        assert_eq!(
642            edges[0].0,
643            SymbolId::new(1).unwrap(),
644            "Reference should attach to innermost symbol SymbolId(1), but attached to SymbolId({})",
645            edges[0].0.to_raw()
646        );
647        assert_eq!(edges[0].1, SymbolId::new(99).unwrap());
648    }
649}