1use 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
20pub 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 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
60pub struct FlattenedScopeCache {
65 scopes: HashMap<FileId, ScopeMap>,
66}
67
68impl FlattenedScopeCache {
69 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(¤t) {
110 let default_vis = ctx
111 .file_languages
112 .get(¤t)
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(¤t) {
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(¤t)
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 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 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 pub fn len(&self) -> usize {
204 self.scopes.len()
205 }
206
207 pub fn is_empty(&self) -> bool {
209 self.scopes.is_empty()
210 }
211}
212
213pub 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 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 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 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
297pub 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 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 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 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 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], 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 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}