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, SourceRange, 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/// One visible symbol candidate before shadowing and ranking.
24struct Candidate {
25    symbol: SymbolId,
26    confidence: f32,
27    rank: u8,
28    path: PathBuf,
29    name: String,
30}
31
32/// Bundles the data needed for scope resolution across files.
33pub struct ResolutionContext {
34    pub symbol_index: SymbolIndex,
35    pub import_adjacency: HashMap<FileId, Vec<FileId>>,
36    pub file_languages: HashMap<FileId, LangId>,
37    pub file_paths: HashMap<FileId, PathBuf>,
38}
39
40impl ResolutionContext {
41    /// Build a ResolutionContext from extraction results and graph data.
42    pub fn from_extractions<F>(
43        extractions: &[F],
44        path_to_file_id: &HashMap<PathBuf, FileId>,
45        import_adjacency: HashMap<FileId, Vec<FileId>>,
46    ) -> Self
47    where
48        F: std::borrow::Borrow<FileExtraction>,
49    {
50        let symbol_index = build_symbol_index(extractions, path_to_file_id);
51        let file_languages: HashMap<_, _> = extractions
52            .iter()
53            .filter_map(|f| {
54                let f = f.borrow();
55                Some((path_to_file_id.get(&f.path)?.to_owned(), f.lang))
56            })
57            .collect();
58        let file_paths: HashMap<_, _> = path_to_file_id
59            .iter()
60            .map(|(path, &fid)| (fid, path.clone()))
61            .collect();
62
63        Self {
64            symbol_index,
65            import_adjacency,
66            file_languages,
67            file_paths,
68        }
69    }
70}
71
72/// Pre-computed visible scope for each file.
73///
74/// Scope = own symbols + public symbols from imported files transitively.
75/// Local symbols take priority over imported (shadowing).
76#[derive(Debug, Clone, Default)]
77pub struct FlattenedScopeCache {
78    scopes: HashMap<FileId, ScopeMap>,
79}
80
81impl FlattenedScopeCache {
82    /// Build the scope cache from the file->symbols index and import adjacency.
83    ///
84    /// For each file, BFS over import edges, collecting public symbols from
85    /// reachable files. Confidence decays with distance:
86    /// - 1.0: own file or direct import, same language
87    /// - 0.8: transitive import, same language
88    /// - 0.6: cross-language imports
89    pub fn build(ctx: &ResolutionContext, diagnostics: &mut Vec<Diagnostic>) -> Self {
90        let mut results: Vec<(FileId, ScopeMap, Vec<Diagnostic>)> = ctx
91            .symbol_index
92            .par_iter()
93            .map(|(&file_id, _)| {
94                let (scope, diags) = Self::compute_scope(file_id, ctx);
95                (file_id, scope, diags)
96            })
97            .collect();
98
99        // The parallel pass returns in hash order; diagnostics must follow the
100        // file path so two runs report the same sequence.
101        results.sort_by(|a, b| ctx.file_paths.get(&a.0).cmp(&ctx.file_paths.get(&b.0)));
102
103        let mut scopes = HashMap::with_capacity(results.len());
104        for (file_id, scope, diags) in results {
105            scopes.insert(file_id, scope);
106            diagnostics.extend(diags);
107        }
108
109        Self { scopes }
110    }
111
112    fn compute_scope(file_id: FileId, ctx: &ResolutionContext) -> (ScopeMap, Vec<Diagnostic>) {
113        let mut diagnostics = Vec::new();
114        let source_lang = ctx.file_languages.get(&file_id).copied();
115        let mut scope: ScopeMap = HashMap::new();
116        let mut candidates: Vec<Candidate> = Vec::new();
117        let mut visited: HashSet<FileId> = HashSet::new();
118        let mut queue: VecDeque<(FileId, usize)> = VecDeque::new();
119
120        queue.push_back((file_id, 0));
121
122        while let Some((current, distance)) = queue.pop_front() {
123            if !visited.insert(current) {
124                continue;
125            }
126
127            if let Some(symbols) = ctx.symbol_index.get(&current) {
128                let default_vis = ctx
129                    .file_languages
130                    .get(&current)
131                    .map(|lang| lang.spec().default_visibility)
132                    .unwrap_or(crate::language::DefaultVisibility::PublicByDefault);
133
134                for (sym_id, name, sym_lang, visibility) in symbols {
135                    let is_public = match visibility {
136                        Some(Visibility::Public) => true,
137                        Some(Visibility::Private) => current == file_id,
138                        None => {
139                            matches!(
140                                default_vis,
141                                crate::language::DefaultVisibility::PublicByDefault
142                            ) || current == file_id
143                        }
144                    };
145
146                    if !is_public {
147                        continue;
148                    }
149
150                    let same_lang = source_lang.is_some() && source_lang == Some(*sym_lang);
151                    let diff_lang = source_lang.is_some() && source_lang != Some(*sym_lang);
152
153                    let confidence = if distance == 0 || (distance == 1 && same_lang) {
154                        CONFIDENCE_OWN_OR_DIRECT
155                    } else if diff_lang {
156                        CONFIDENCE_CROSS_LANGUAGE
157                    } else {
158                        CONFIDENCE_TRANSITIVE
159                    };
160
161                    // Rank classes follow the shadowing rule: the own file wins,
162                    // then a direct same-language import, then a transitive one,
163                    // and a cross-language import comes last.
164                    let rank = if distance == 0 {
165                        0u8
166                    } else if distance == 1 && same_lang {
167                        1
168                    } else if same_lang {
169                        2
170                    } else {
171                        3
172                    };
173
174                    candidates.push(Candidate {
175                        symbol: *sym_id,
176                        name: name.clone(),
177                        confidence,
178                        rank,
179                        path: ctx.file_paths.get(&current).cloned().unwrap_or_default(),
180                    });
181                }
182            }
183
184            if let Some(neighbors) = ctx.import_adjacency.get(&current) {
185                for &neighbor in neighbors {
186                    if !visited.contains(&neighbor) {
187                        queue.push_back((neighbor, distance + 1));
188                    } else if neighbor == file_id {
189                        let path = ctx
190                            .file_paths
191                            .get(&current)
192                            .cloned()
193                            .unwrap_or_else(|| PathBuf::from("<unknown>"));
194                        let root_path = ctx
195                            .file_paths
196                            .get(&file_id)
197                            .map(|p| p.display().to_string())
198                            .unwrap_or_else(|| "<unknown>".to_string());
199                        diagnostics.push(Diagnostic {
200                            path,
201                            severity: Severity::Warning,
202                            message: format!(
203                                "circular import: {} -> {}",
204                                current.to_raw(),
205                                root_path
206                            ),
207                            source_range: None,
208                        });
209                    }
210                }
211            }
212        }
213
214        // Nearer definitions shadow farther ones. The remaining candidates keep
215        // a total order: rank, confidence, path, then identifier. The raw
216        // identifier comes last, because it is unique only inside a run.
217        let mut grouped: HashMap<String, Vec<Candidate>> = HashMap::new();
218        for candidate in candidates {
219            grouped
220                .entry(candidate.name.clone())
221                .or_default()
222                .push(candidate);
223        }
224        for (name, mut group) in grouped {
225            let best = group.iter().map(|candidate| candidate.rank).min();
226            if let Some(best) = best {
227                group.retain(|candidate| candidate.rank == best);
228            }
229            group.sort_by(|a, b| {
230                a.rank
231                    .cmp(&b.rank)
232                    .then(b.confidence.total_cmp(&a.confidence))
233                    .then(a.path.cmp(&b.path))
234                    .then(a.symbol.to_raw().cmp(&b.symbol.to_raw()))
235            });
236            scope.insert(
237                name,
238                group
239                    .into_iter()
240                    .map(|candidate| (candidate.symbol, candidate.confidence))
241                    .collect(),
242            );
243        }
244
245        (scope, diagnostics)
246    }
247
248    /// Look up a name in a file's flattened scope.
249    ///
250    /// Returns matching symbols with confidence scores, or None if not found.
251    pub fn resolve(&self, file_id: FileId, name: &str) -> Option<&[(SymbolId, f32)]> {
252        self.scopes
253            .get(&file_id)
254            .and_then(|s| s.get(name).map(|v| v.as_slice()))
255    }
256
257    /// Full scope for a file: name to candidates with confidence.
258    pub fn scope(&self, file_id: FileId) -> Option<&ScopeMap> {
259        self.scopes.get(&file_id)
260    }
261
262    /// Iterate over every file scope.
263    pub fn iter_scopes(&self) -> impl Iterator<Item = (FileId, &ScopeMap)> {
264        self.scopes.iter().map(|(&file_id, scope)| (file_id, scope))
265    }
266
267    /// Returns the number of scopes in the cache.
268    pub fn len(&self) -> usize {
269        self.scopes.len()
270    }
271
272    /// Returns true if the cache is empty.
273    pub fn is_empty(&self) -> bool {
274        self.scopes.is_empty()
275    }
276}
277
278/// One resolved use of a name, with the site that produced it.
279///
280/// The graph edge alone names the source and target symbols; this record keeps
281/// the reference range too, so a consumer can point at the exact use instead of
282/// re-deriving the mapping from names.
283#[derive(Debug, Clone, PartialEq)]
284pub struct ResolvedReference {
285    /// Path of the referencing file, as extracted.
286    pub file_path: PathBuf,
287    /// Range of the unresolved reference itself.
288    pub range: SourceRange,
289    /// Symbol that contains the reference.
290    pub source: SymbolId,
291    /// Symbol the name resolves to.
292    pub target: SymbolId,
293    /// Confidence threaded from the scope cache.
294    pub confidence: f32,
295}
296
297/// Resolve every reference and keep its use site.
298///
299/// Records follow extraction order and, within one file, reference order. A
300/// reference with no scope match emits one Warning and no record; the warnings
301/// are appended to `diagnostics` exactly as `resolve_all_references` reports
302/// them. Confidence is threaded from the `FlattenedScopeCache` (1.0
303/// local/direct, 0.8 transitive, 0.6 cross-language).
304///
305/// A self-recursive reference, such as a function that calls itself, keeps
306/// `source == target`. The record is a real use site; the graph folds it into a
307/// self-loop `Reference` edge, which is what classifies a self-recursive unit
308/// as a `SelfLoop` deployability hint.
309pub fn resolve_references_detailed<F>(
310    extractions: &[F],
311    path_to_file_id: &HashMap<PathBuf, FileId>,
312    scope_cache: &FlattenedScopeCache,
313    diagnostics: &mut Vec<Diagnostic>,
314) -> Vec<ResolvedReference>
315where
316    F: std::borrow::Borrow<FileExtraction> + Sync,
317{
318    #[allow(clippy::type_complexity)]
319    let results: Vec<(Vec<ResolvedReference>, Vec<Diagnostic>)> = extractions
320        .par_iter()
321        .map(|file_ext| {
322            let file_ext = file_ext.borrow();
323            let mut local_refs = Vec::new();
324            let mut local_diags = Vec::new();
325
326            let file_id = match path_to_file_id.get(&file_ext.path) {
327                Some(&id) => id,
328                None => return (local_refs, local_diags),
329            };
330
331            let file_path = &file_ext.path;
332            for ref_ in &file_ext.references {
333                if let Some(matches) = scope_cache.resolve(file_id, &ref_.name) {
334                    // Find the innermost source symbol that contains this reference range
335                    // Pick the symbol with the smallest byte span length
336                    let source_sym = file_ext
337                        .symbols
338                        .iter()
339                        .filter(|s| {
340                            s.source_range.byte_start <= ref_.range.byte_start
341                                && s.source_range.byte_end >= ref_.range.byte_end
342                        })
343                        .min_by_key(|s| s.source_range.byte_end - s.source_range.byte_start);
344
345                    if let Some(source) = source_sym {
346                        for &(target_id, confidence) in matches {
347                            local_refs.push(ResolvedReference {
348                                file_path: file_path.clone(),
349                                range: ref_.range.clone(),
350                                source: source.id,
351                                target: target_id,
352                                confidence,
353                            });
354                        }
355                    }
356                } else {
357                    local_diags.push(Diagnostic {
358                        path: file_path.clone(),
359                        severity: Severity::Warning,
360                        message: format!("unresolved reference: '{}'", ref_.name),
361                        source_range: Some(ref_.range.clone()),
362                    });
363                }
364            }
365            (local_refs, local_diags)
366        })
367        .collect();
368
369    let mut resolved = Vec::new();
370    for (mut local_refs, local_diags) in results {
371        resolved.append(&mut local_refs);
372        diagnostics.extend(local_diags);
373    }
374    resolved
375}
376
377/// Fold resolved use sites into one edge per `(source, target)` pair.
378///
379/// Duplicates max-merge their confidence and the result is ordered by symbol
380/// id, so the edge list does not depend on discovery or resolution order. A
381/// self-recursive use site folds into a self edge, which is intended: the SCC
382/// pass reports such a unit as a `SelfLoop` hint.
383pub fn reference_edges(resolved: &[ResolvedReference]) -> Vec<(SymbolId, SymbolId, f32)> {
384    let mut seen: HashMap<(SymbolId, SymbolId), f32> = HashMap::with_capacity(resolved.len());
385    for reference in resolved {
386        seen.entry((reference.source, reference.target))
387            .and_modify(|confidence| *confidence = confidence.max(reference.confidence))
388            .or_insert(reference.confidence);
389    }
390    let mut edges: Vec<_> = seen
391        .into_iter()
392        .map(|((source, target), confidence)| (source, target, confidence))
393        .collect();
394    edges.sort_by_key(|(source, target, _)| (source.to_raw(), target.to_raw()));
395    edges
396}
397
398/// Resolve all references across extracted files.
399///
400/// Returns a list of (source_symbol_id, target_symbol_id, confidence) triples
401/// representing ReferenceEdges to add. Confidence is threaded from the
402/// FlattenedScopeCache (1.0 local/direct, 0.8 transitive, 0.6 cross-language).
403/// Warnings for unresolved references are appended to `diagnostics`.
404pub fn resolve_all_references<F>(
405    extractions: &[F],
406    path_to_file_id: &HashMap<PathBuf, FileId>,
407    scope_cache: &FlattenedScopeCache,
408    diagnostics: &mut Vec<Diagnostic>,
409) -> Vec<(SymbolId, SymbolId, f32)>
410where
411    F: std::borrow::Borrow<FileExtraction> + Sync,
412{
413    let resolved =
414        resolve_references_detailed(extractions, path_to_file_id, scope_cache, diagnostics);
415    reference_edges(&resolved)
416}
417
418/// Build a symbol index from extracted files and a path-to-FileId mapping.
419///
420/// Returns: SymbolIndex
421pub fn build_symbol_index<F>(
422    extractions: &[F],
423    path_to_file_id: &HashMap<PathBuf, FileId>,
424) -> SymbolIndex
425where
426    F: std::borrow::Borrow<FileExtraction>,
427{
428    let mut index: SymbolIndex = HashMap::new();
429
430    for file_ext in extractions {
431        let file_ext = file_ext.borrow();
432        if let Some(&file_id) = path_to_file_id.get(&file_ext.path) {
433            let entries: Vec<_> = file_ext
434                .symbols
435                .iter()
436                .map(|s| (s.id, s.name.clone(), s.language, s.visibility))
437                .collect();
438            index.entry(file_id).or_default().extend(entries);
439        }
440    }
441
442    index
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::path::PathBuf;
449
450    #[test]
451    fn empty_cache() {
452        let cache = FlattenedScopeCache {
453            scopes: HashMap::new(),
454        };
455        assert!(cache.is_empty());
456        assert_eq!(cache.len(), 0);
457        assert!(cache.resolve(FileId::new(1).unwrap(), "foo").is_none());
458    }
459
460    #[test]
461    fn detailed_resolution_keeps_each_use_site_and_the_triples_are_unchanged() {
462        use crate::model::{LineColumn, Symbol, SymbolKind, UnresolvedReference};
463
464        fn range(start: usize, end: usize) -> SourceRange {
465            SourceRange {
466                byte_start: start,
467                byte_end: end,
468                start: LineColumn {
469                    line: 0,
470                    column: start,
471                },
472                end: LineColumn {
473                    line: 0,
474                    column: end,
475                },
476            }
477        }
478
479        fn symbol(id: u32, name: &str, start: usize, end: usize, path: &std::path::Path) -> Symbol {
480            Symbol {
481                id: SymbolId::new(id).unwrap(),
482                name: name.to_string(),
483                kind: SymbolKind::Function,
484                language: LangId::Python,
485                file_path: path.to_path_buf(),
486                source_range: range(start, end),
487                name_range: None,
488                visibility: None,
489                signature: None,
490                docstring: None,
491                is_async: false,
492            }
493        }
494
495        let path = PathBuf::from("a.py");
496        let file_id = FileId::new(1).unwrap();
497        let mut file = FileExtraction::empty(path.clone(), LangId::Python);
498        file.symbols = vec![
499            symbol(1, "caller", 0, 100, &path),
500            symbol(2, "helper", 200, 210, &path),
501        ];
502        file.references = vec![
503            UnresolvedReference {
504                name: "helper".into(),
505                range: range(10, 16),
506            },
507            UnresolvedReference {
508                name: "helper".into(),
509                range: range(30, 36),
510            },
511        ];
512        let mut symbol_index: SymbolIndex = HashMap::new();
513        symbol_index.insert(
514            file_id,
515            vec![
516                (
517                    SymbolId::new(1).unwrap(),
518                    "caller".into(),
519                    LangId::Python,
520                    None,
521                ),
522                (
523                    SymbolId::new(2).unwrap(),
524                    "helper".into(),
525                    LangId::Python,
526                    None,
527                ),
528            ],
529        );
530        let ctx = ResolutionContext {
531            symbol_index,
532            import_adjacency: HashMap::new(),
533            file_languages: HashMap::from([(file_id, LangId::Python)]),
534            file_paths: HashMap::from([(file_id, path.clone())]),
535        };
536        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
537        let extractions = vec![file];
538        let paths = HashMap::from([(path, file_id)]);
539
540        let mut diagnostics = Vec::new();
541        let resolved = resolve_references_detailed(&extractions, &paths, &cache, &mut diagnostics);
542
543        assert!(diagnostics.is_empty());
544        assert_eq!(resolved.len(), 2, "one record per use site");
545        assert_eq!(resolved[0].range.byte_start, 10);
546        assert_eq!(resolved[1].range.byte_start, 30);
547        assert!(
548            resolved
549                .iter()
550                .all(|record| record.source == SymbolId::new(1).unwrap())
551        );
552        assert!(
553            resolved.iter().all(
554                |record| record.target == SymbolId::new(2).unwrap() && record.confidence == 1.0
555            )
556        );
557
558        let mut wrapper_diagnostics = Vec::new();
559        let triples =
560            resolve_all_references(&extractions, &paths, &cache, &mut wrapper_diagnostics);
561        assert!(wrapper_diagnostics.is_empty());
562        assert_eq!(
563            triples,
564            vec![(SymbolId::new(1).unwrap(), SymbolId::new(2).unwrap(), 1.0)],
565            "the wrapper max-merges the two use sites into one edge"
566        );
567    }
568
569    #[test]
570    fn scope_cache_resolve_own_file() {
571        let mut symbol_index: SymbolIndex = HashMap::new();
572        symbol_index.insert(
573            FileId::new(1).unwrap(),
574            vec![(
575                SymbolId::new(10).unwrap(),
576                "main".into(),
577                LangId::Python,
578                None,
579            )],
580        );
581
582        let ctx = ResolutionContext {
583            symbol_index,
584            import_adjacency: HashMap::new(),
585            file_languages: HashMap::from([(FileId::new(1).unwrap(), LangId::Python)]),
586            file_paths: HashMap::new(),
587        };
588
589        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
590        let result = cache.resolve(FileId::new(1).unwrap(), "main");
591        assert!(result.is_some());
592        let matches = result.unwrap();
593        assert_eq!(matches.len(), 1);
594        assert_eq!(matches[0].0, SymbolId::new(10).unwrap());
595        assert_eq!(matches[0].1, 1.0);
596    }
597
598    #[test]
599    fn scope_cache_resolve_imported_symbol() {
600        let mut symbol_index = HashMap::new();
601        symbol_index.insert(FileId::new(1).unwrap(), vec![]);
602        symbol_index.insert(
603            FileId::new(2).unwrap(),
604            vec![(
605                SymbolId::new(20).unwrap(),
606                "helper".into(),
607                LangId::Python,
608                Some(Visibility::Public),
609            )],
610        );
611
612        let ctx = ResolutionContext {
613            symbol_index,
614            import_adjacency: HashMap::from([(
615                FileId::new(1).unwrap(),
616                vec![FileId::new(2).unwrap()],
617            )]),
618            file_languages: HashMap::from([
619                (FileId::new(1).unwrap(), LangId::Python),
620                (FileId::new(2).unwrap(), LangId::Python),
621            ]),
622            file_paths: HashMap::new(),
623        };
624
625        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
626        let result = cache.resolve(FileId::new(1).unwrap(), "helper");
627        assert!(result.is_some());
628        let matches = result.unwrap();
629        assert_eq!(matches.len(), 1);
630        assert_eq!(matches[0].0, SymbolId::new(20).unwrap());
631        assert_eq!(matches[0].1, 1.0);
632    }
633
634    #[test]
635    fn scope_cache_missing_symbol() {
636        let mut symbol_index = HashMap::new();
637        symbol_index.insert(
638            FileId::new(1).unwrap(),
639            vec![(
640                SymbolId::new(10).unwrap(),
641                "foo".into(),
642                LangId::Python,
643                None,
644            )],
645        );
646
647        let ctx = ResolutionContext {
648            symbol_index,
649            import_adjacency: HashMap::new(),
650            file_languages: HashMap::from([(FileId::new(1).unwrap(), LangId::Python)]),
651            file_paths: HashMap::new(),
652        };
653
654        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
655        assert!(cache.resolve(FileId::new(1).unwrap(), "bar").is_none());
656    }
657
658    #[test]
659    fn scope_cache_cycle_safe() {
660        let mut symbol_index = HashMap::new();
661        symbol_index.insert(
662            FileId::new(1).unwrap(),
663            vec![(
664                SymbolId::new(10).unwrap(),
665                "a".into(),
666                LangId::Python,
667                Some(Visibility::Public),
668            )],
669        );
670        symbol_index.insert(
671            FileId::new(2).unwrap(),
672            vec![(
673                SymbolId::new(20).unwrap(),
674                "b".into(),
675                LangId::Python,
676                Some(Visibility::Public),
677            )],
678        );
679
680        // Cycle: 0 -> 1 -> 0
681        let ctx = ResolutionContext {
682            symbol_index,
683            import_adjacency: HashMap::from([
684                (FileId::new(1).unwrap(), vec![FileId::new(2).unwrap()]),
685                (FileId::new(2).unwrap(), vec![FileId::new(1).unwrap()]),
686            ]),
687            file_languages: HashMap::from([
688                (FileId::new(1).unwrap(), LangId::Python),
689                (FileId::new(2).unwrap(), LangId::Python),
690            ]),
691            file_paths: HashMap::new(),
692        };
693
694        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
695        // Should not infinite loop
696        assert!(cache.resolve(FileId::new(1).unwrap(), "b").is_some());
697        assert!(cache.resolve(FileId::new(2).unwrap(), "a").is_some());
698    }
699
700    #[test]
701    fn scope_cache_cross_language_confidence() {
702        let mut symbol_index = HashMap::new();
703        symbol_index.insert(FileId::new(1).unwrap(), vec![]);
704        symbol_index.insert(
705            FileId::new(2).unwrap(),
706            vec![(
707                SymbolId::new(20).unwrap(),
708                "util".into(),
709                LangId::Rust,
710                Some(Visibility::Public),
711            )],
712        );
713
714        let ctx = ResolutionContext {
715            symbol_index,
716            import_adjacency: HashMap::from([(
717                FileId::new(1).unwrap(),
718                vec![FileId::new(2).unwrap()],
719            )]),
720            file_languages: HashMap::from([
721                (FileId::new(1).unwrap(), LangId::Python),
722                (FileId::new(2).unwrap(), LangId::Rust),
723            ]),
724            file_paths: HashMap::new(),
725        };
726
727        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
728        let result = cache.resolve(FileId::new(1).unwrap(), "util");
729        assert!(result.is_some());
730        assert_eq!(result.unwrap()[0].1, 0.6);
731    }
732
733    #[test]
734    fn resolve_references_creates_edges() {
735        use crate::model::{LineColumn, SourceRange, Symbol, SymbolKind, UnresolvedReference};
736
737        let sym_a = Symbol {
738            id: SymbolId::new(1).unwrap(),
739            name: "caller".into(),
740            kind: SymbolKind::Function,
741            language: LangId::Python,
742            file_path: PathBuf::from("/proj/a.py"),
743            source_range: SourceRange {
744                byte_start: 0,
745                byte_end: 50,
746                start: LineColumn { line: 0, column: 0 },
747                end: LineColumn { line: 2, column: 0 },
748            },
749            name_range: None,
750            visibility: None,
751            signature: None,
752            docstring: None,
753            is_async: false,
754        };
755
756        let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
757        file.symbols = vec![sym_a];
758        file.references = vec![UnresolvedReference {
759            name: "helper".into(),
760            range: SourceRange {
761                byte_start: 20,
762                byte_end: 26,
763                start: LineColumn { line: 1, column: 4 },
764                end: LineColumn {
765                    line: 1,
766                    column: 10,
767                },
768            },
769        }];
770
771        let mut path_to_file_id = HashMap::new();
772        path_to_file_id.insert(PathBuf::from("/proj/a.py"), FileId::new(1).unwrap());
773
774        let mut scopes: HashMap<FileId, ScopeMap> = HashMap::new();
775        let mut scope = HashMap::new();
776        scope.insert("helper".into(), vec![(SymbolId::new(99).unwrap(), 1.0)]);
777        scopes.insert(FileId::new(1).unwrap(), scope);
778
779        let cache = FlattenedScopeCache { scopes };
780
781        let edges = resolve_all_references(&[file], &path_to_file_id, &cache, &mut Vec::new());
782        assert_eq!(edges.len(), 1);
783        assert_eq!(edges[0].0, SymbolId::new(1).unwrap());
784        assert_eq!(edges[0].1, SymbolId::new(99).unwrap());
785        assert_eq!(edges[0].2, 1.0);
786    }
787
788    #[test]
789    fn resolve_references_selects_innermost_enclosing_symbol_regardless_of_vector_order() {
790        use crate::model::{LineColumn, SourceRange, Symbol, SymbolKind, UnresolvedReference};
791
792        // Inner method (span 40: 10..50)
793        let inner_method = Symbol {
794            id: SymbolId::new(1).unwrap(),
795            name: "inner_method".into(),
796            kind: SymbolKind::Method,
797            language: LangId::Python,
798            file_path: PathBuf::from("/proj/a.py"),
799            source_range: SourceRange {
800                byte_start: 10,
801                byte_end: 50,
802                start: LineColumn { line: 1, column: 0 },
803                end: LineColumn { line: 3, column: 0 },
804            },
805            name_range: None,
806            visibility: None,
807            signature: None,
808            docstring: None,
809            is_async: false,
810        };
811
812        // Outer class (span 100: 0..100) placed AFTER inner_method in vector
813        let outer_class = Symbol {
814            id: SymbolId::new(2).unwrap(),
815            name: "OuterClass".into(),
816            kind: SymbolKind::Class,
817            language: LangId::Python,
818            file_path: PathBuf::from("/proj/a.py"),
819            source_range: SourceRange {
820                byte_start: 0,
821                byte_end: 100,
822                start: LineColumn { line: 0, column: 0 },
823                end: LineColumn { line: 5, column: 0 },
824            },
825            name_range: None,
826            visibility: None,
827            signature: None,
828            docstring: None,
829            is_async: false,
830        };
831
832        let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
833        file.symbols = vec![inner_method, outer_class]; // Order: inner first, outer second
834        file.references = vec![UnresolvedReference {
835            name: "helper".into(),
836            range: SourceRange {
837                byte_start: 20,
838                byte_end: 26,
839                start: LineColumn { line: 2, column: 4 },
840                end: LineColumn {
841                    line: 2,
842                    column: 10,
843                },
844            },
845        }];
846
847        let mut path_to_file_id = HashMap::new();
848        path_to_file_id.insert(PathBuf::from("/proj/a.py"), FileId::new(1).unwrap());
849
850        let mut scopes: HashMap<FileId, ScopeMap> = HashMap::new();
851        let mut scope = HashMap::new();
852        scope.insert("helper".into(), vec![(SymbolId::new(99).unwrap(), 1.0)]);
853        scopes.insert(FileId::new(1).unwrap(), scope);
854
855        let cache = FlattenedScopeCache { scopes };
856
857        let edges = resolve_all_references(&[file], &path_to_file_id, &cache, &mut Vec::new());
858        assert_eq!(edges.len(), 1);
859        // Must resolve from SymbolId(1) (inner_method), NOT SymbolId(2) (outer_class)
860        assert_eq!(
861            edges[0].0,
862            SymbolId::new(1).unwrap(),
863            "Reference should attach to innermost symbol SymbolId(1), but attached to SymbolId({})",
864            edges[0].0.to_raw()
865        );
866        assert_eq!(edges[0].1, SymbolId::new(99).unwrap());
867    }
868
869    #[test]
870    fn local_symbol_shadows_the_imported_symbol() {
871        let mut symbol_index: SymbolIndex = HashMap::new();
872        symbol_index.insert(
873            FileId::new(1).unwrap(),
874            vec![(
875                SymbolId::new(10).unwrap(),
876                "helper".into(),
877                LangId::Python,
878                Some(Visibility::Public),
879            )],
880        );
881        symbol_index.insert(
882            FileId::new(2).unwrap(),
883            vec![(
884                SymbolId::new(20).unwrap(),
885                "helper".into(),
886                LangId::Python,
887                Some(Visibility::Public),
888            )],
889        );
890
891        let ctx = ResolutionContext {
892            symbol_index,
893            import_adjacency: HashMap::from([(
894                FileId::new(1).unwrap(),
895                vec![FileId::new(2).unwrap()],
896            )]),
897            file_languages: HashMap::from([
898                (FileId::new(1).unwrap(), LangId::Python),
899                (FileId::new(2).unwrap(), LangId::Python),
900            ]),
901            file_paths: HashMap::from([
902                (FileId::new(1).unwrap(), PathBuf::from("/proj/main.py")),
903                (FileId::new(2).unwrap(), PathBuf::from("/proj/lib.py")),
904            ]),
905        };
906
907        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
908        let matches = cache.resolve(FileId::new(1).unwrap(), "helper").unwrap();
909        assert_eq!(
910            matches.len(),
911            1,
912            "the local definition must shadow the imported one"
913        );
914        assert_eq!(matches[0].0, SymbolId::new(10).unwrap());
915        assert_eq!(matches[0].1, 1.0);
916    }
917
918    #[test]
919    fn imported_candidates_are_ranked_by_path_not_by_id() {
920        let mut symbol_index: SymbolIndex = HashMap::new();
921        symbol_index.insert(FileId::new(1).unwrap(), vec![]);
922        // File 2 sorts before file 3 by path but holds the higher symbol id.
923        symbol_index.insert(
924            FileId::new(2).unwrap(),
925            vec![(
926                SymbolId::new(30).unwrap(),
927                "util".into(),
928                LangId::Python,
929                Some(Visibility::Public),
930            )],
931        );
932        symbol_index.insert(
933            FileId::new(3).unwrap(),
934            vec![(
935                SymbolId::new(20).unwrap(),
936                "util".into(),
937                LangId::Python,
938                Some(Visibility::Public),
939            )],
940        );
941
942        let ctx = ResolutionContext {
943            symbol_index,
944            import_adjacency: HashMap::from([(
945                FileId::new(1).unwrap(),
946                vec![FileId::new(2).unwrap(), FileId::new(3).unwrap()],
947            )]),
948            file_languages: HashMap::from([
949                (FileId::new(1).unwrap(), LangId::Python),
950                (FileId::new(2).unwrap(), LangId::Python),
951                (FileId::new(3).unwrap(), LangId::Python),
952            ]),
953            file_paths: HashMap::from([
954                (FileId::new(1).unwrap(), PathBuf::from("/proj/app.py")),
955                (FileId::new(2).unwrap(), PathBuf::from("/proj/a_util.py")),
956                (FileId::new(3).unwrap(), PathBuf::from("/proj/z_util.py")),
957            ]),
958        };
959
960        let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
961        let matches = cache.resolve(FileId::new(1).unwrap(), "util").unwrap();
962        assert_eq!(
963            matches.len(),
964            2,
965            "an ambiguous import keeps both candidates"
966        );
967        assert_eq!(
968            matches[0].0,
969            SymbolId::new(30).unwrap(),
970            "candidate order must follow the file path, not the raw symbol id"
971        );
972    }
973}