1use 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
23pub 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 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#[derive(Debug, Clone)]
68pub struct FlattenedScopeCache {
69 scopes: HashMap<FileId, ScopeMap>,
70}
71
72impl FlattenedScopeCache {
73 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(¤t) {
114 let default_vis = ctx
115 .file_languages
116 .get(¤t)
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(¤t) {
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(¤t)
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 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 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 pub fn scope(&self, file_id: FileId) -> Option<&ScopeMap> {
208 self.scopes.get(&file_id)
209 }
210
211 pub fn iter_scopes(&self) -> impl Iterator<Item = (FileId, &ScopeMap)> {
213 self.scopes.iter().map(|(&file_id, scope)| (file_id, scope))
214 }
215
216 pub fn len(&self) -> usize {
218 self.scopes.len()
219 }
220
221 pub fn is_empty(&self) -> bool {
223 self.scopes.is_empty()
224 }
225}
226
227pub 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 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 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 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
311pub 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 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 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 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 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]; 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 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}