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