use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use rayon::prelude::*;
use crate::error::{Diagnostic, Severity};
use crate::graph::edge::{
CONFIDENCE_CROSS_LANGUAGE, CONFIDENCE_OWN_OR_DIRECT, CONFIDENCE_TRANSITIVE,
};
use crate::language::LangId;
use crate::model::{FileExtraction, FileId, SourceRange, SymbolId, Visibility};
pub type ScopeMap = HashMap<String, Vec<(SymbolId, f32)>>;
pub(crate) type SymbolIndexEntry = (SymbolId, String, LangId, Option<Visibility>);
pub(crate) type SymbolIndex = HashMap<FileId, Vec<SymbolIndexEntry>>;
struct Candidate {
symbol: SymbolId,
confidence: f32,
rank: u8,
path: PathBuf,
name: String,
}
pub struct ResolutionContext {
pub symbol_index: SymbolIndex,
pub import_adjacency: HashMap<FileId, Vec<FileId>>,
pub file_languages: HashMap<FileId, LangId>,
pub file_paths: HashMap<FileId, PathBuf>,
}
impl ResolutionContext {
pub fn from_extractions<F>(
extractions: &[F],
path_to_file_id: &HashMap<PathBuf, FileId>,
import_adjacency: HashMap<FileId, Vec<FileId>>,
) -> Self
where
F: std::borrow::Borrow<FileExtraction>,
{
let symbol_index = build_symbol_index(extractions, path_to_file_id);
let file_languages: HashMap<_, _> = extractions
.iter()
.filter_map(|f| {
let f = f.borrow();
Some((path_to_file_id.get(&f.path)?.to_owned(), f.lang))
})
.collect();
let file_paths: HashMap<_, _> = path_to_file_id
.iter()
.map(|(path, &fid)| (fid, path.clone()))
.collect();
Self {
symbol_index,
import_adjacency,
file_languages,
file_paths,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FlattenedScopeCache {
scopes: HashMap<FileId, ScopeMap>,
}
impl FlattenedScopeCache {
pub fn build(ctx: &ResolutionContext, diagnostics: &mut Vec<Diagnostic>) -> Self {
let mut results: Vec<(FileId, ScopeMap, Vec<Diagnostic>)> = ctx
.symbol_index
.par_iter()
.map(|(&file_id, _)| {
let (scope, diags) = Self::compute_scope(file_id, ctx);
(file_id, scope, diags)
})
.collect();
results.sort_by(|a, b| ctx.file_paths.get(&a.0).cmp(&ctx.file_paths.get(&b.0)));
let mut scopes = HashMap::with_capacity(results.len());
for (file_id, scope, diags) in results {
scopes.insert(file_id, scope);
diagnostics.extend(diags);
}
Self { scopes }
}
fn compute_scope(file_id: FileId, ctx: &ResolutionContext) -> (ScopeMap, Vec<Diagnostic>) {
let mut diagnostics = Vec::new();
let source_lang = ctx.file_languages.get(&file_id).copied();
let mut scope: ScopeMap = HashMap::new();
let mut candidates: Vec<Candidate> = Vec::new();
let mut visited: HashSet<FileId> = HashSet::new();
let mut queue: VecDeque<(FileId, usize)> = VecDeque::new();
queue.push_back((file_id, 0));
while let Some((current, distance)) = queue.pop_front() {
if !visited.insert(current) {
continue;
}
if let Some(symbols) = ctx.symbol_index.get(¤t) {
let default_vis = ctx
.file_languages
.get(¤t)
.map(|lang| lang.spec().default_visibility)
.unwrap_or(crate::language::DefaultVisibility::PublicByDefault);
for (sym_id, name, sym_lang, visibility) in symbols {
let is_public = match visibility {
Some(Visibility::Public) => true,
Some(Visibility::Private) => current == file_id,
None => {
matches!(
default_vis,
crate::language::DefaultVisibility::PublicByDefault
) || current == file_id
}
};
if !is_public {
continue;
}
let same_lang = source_lang.is_some() && source_lang == Some(*sym_lang);
let diff_lang = source_lang.is_some() && source_lang != Some(*sym_lang);
let confidence = if distance == 0 || (distance == 1 && same_lang) {
CONFIDENCE_OWN_OR_DIRECT
} else if diff_lang {
CONFIDENCE_CROSS_LANGUAGE
} else {
CONFIDENCE_TRANSITIVE
};
let rank = if distance == 0 {
0u8
} else if distance == 1 && same_lang {
1
} else if same_lang {
2
} else {
3
};
candidates.push(Candidate {
symbol: *sym_id,
name: name.clone(),
confidence,
rank,
path: ctx.file_paths.get(¤t).cloned().unwrap_or_default(),
});
}
}
if let Some(neighbors) = ctx.import_adjacency.get(¤t) {
for &neighbor in neighbors {
if !visited.contains(&neighbor) {
queue.push_back((neighbor, distance + 1));
} else if neighbor == file_id {
let path = ctx
.file_paths
.get(¤t)
.cloned()
.unwrap_or_else(|| PathBuf::from("<unknown>"));
let root_path = ctx
.file_paths
.get(&file_id)
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
diagnostics.push(Diagnostic {
path,
severity: Severity::Warning,
message: format!(
"circular import: {} -> {}",
current.to_raw(),
root_path
),
source_range: None,
});
}
}
}
}
let mut grouped: HashMap<String, Vec<Candidate>> = HashMap::new();
for candidate in candidates {
grouped
.entry(candidate.name.clone())
.or_default()
.push(candidate);
}
for (name, mut group) in grouped {
let best = group.iter().map(|candidate| candidate.rank).min();
if let Some(best) = best {
group.retain(|candidate| candidate.rank == best);
}
group.sort_by(|a, b| {
a.rank
.cmp(&b.rank)
.then(b.confidence.total_cmp(&a.confidence))
.then(a.path.cmp(&b.path))
.then(a.symbol.to_raw().cmp(&b.symbol.to_raw()))
});
scope.insert(
name,
group
.into_iter()
.map(|candidate| (candidate.symbol, candidate.confidence))
.collect(),
);
}
(scope, diagnostics)
}
pub fn resolve(&self, file_id: FileId, name: &str) -> Option<&[(SymbolId, f32)]> {
self.scopes
.get(&file_id)
.and_then(|s| s.get(name).map(|v| v.as_slice()))
}
pub fn scope(&self, file_id: FileId) -> Option<&ScopeMap> {
self.scopes.get(&file_id)
}
pub fn iter_scopes(&self) -> impl Iterator<Item = (FileId, &ScopeMap)> {
self.scopes.iter().map(|(&file_id, scope)| (file_id, scope))
}
pub fn len(&self) -> usize {
self.scopes.len()
}
pub fn is_empty(&self) -> bool {
self.scopes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedReference {
pub file_path: PathBuf,
pub range: SourceRange,
pub source: SymbolId,
pub target: SymbolId,
pub confidence: f32,
}
pub fn resolve_references_detailed<F>(
extractions: &[F],
path_to_file_id: &HashMap<PathBuf, FileId>,
scope_cache: &FlattenedScopeCache,
diagnostics: &mut Vec<Diagnostic>,
) -> Vec<ResolvedReference>
where
F: std::borrow::Borrow<FileExtraction> + Sync,
{
#[allow(clippy::type_complexity)]
let results: Vec<(Vec<ResolvedReference>, Vec<Diagnostic>)> = extractions
.par_iter()
.map(|file_ext| {
let file_ext = file_ext.borrow();
let mut local_refs = Vec::new();
let mut local_diags = Vec::new();
let file_id = match path_to_file_id.get(&file_ext.path) {
Some(&id) => id,
None => return (local_refs, local_diags),
};
let file_path = &file_ext.path;
for ref_ in &file_ext.references {
if let Some(matches) = scope_cache.resolve(file_id, &ref_.name) {
let source_sym = file_ext
.symbols
.iter()
.filter(|s| {
s.source_range.byte_start <= ref_.range.byte_start
&& s.source_range.byte_end >= ref_.range.byte_end
})
.min_by_key(|s| s.source_range.byte_end - s.source_range.byte_start);
if let Some(source) = source_sym {
for &(target_id, confidence) in matches {
local_refs.push(ResolvedReference {
file_path: file_path.clone(),
range: ref_.range.clone(),
source: source.id,
target: target_id,
confidence,
});
}
}
} else {
local_diags.push(Diagnostic {
path: file_path.clone(),
severity: Severity::Warning,
message: format!("unresolved reference: '{}'", ref_.name),
source_range: Some(ref_.range.clone()),
});
}
}
(local_refs, local_diags)
})
.collect();
let mut resolved = Vec::new();
for (mut local_refs, local_diags) in results {
resolved.append(&mut local_refs);
diagnostics.extend(local_diags);
}
resolved
}
pub fn reference_edges(resolved: &[ResolvedReference]) -> Vec<(SymbolId, SymbolId, f32)> {
let mut seen: HashMap<(SymbolId, SymbolId), f32> = HashMap::with_capacity(resolved.len());
for reference in resolved {
seen.entry((reference.source, reference.target))
.and_modify(|confidence| *confidence = confidence.max(reference.confidence))
.or_insert(reference.confidence);
}
let mut edges: Vec<_> = seen
.into_iter()
.map(|((source, target), confidence)| (source, target, confidence))
.collect();
edges.sort_by_key(|(source, target, _)| (source.to_raw(), target.to_raw()));
edges
}
pub fn resolve_all_references<F>(
extractions: &[F],
path_to_file_id: &HashMap<PathBuf, FileId>,
scope_cache: &FlattenedScopeCache,
diagnostics: &mut Vec<Diagnostic>,
) -> Vec<(SymbolId, SymbolId, f32)>
where
F: std::borrow::Borrow<FileExtraction> + Sync,
{
let resolved =
resolve_references_detailed(extractions, path_to_file_id, scope_cache, diagnostics);
reference_edges(&resolved)
}
pub fn build_symbol_index<F>(
extractions: &[F],
path_to_file_id: &HashMap<PathBuf, FileId>,
) -> SymbolIndex
where
F: std::borrow::Borrow<FileExtraction>,
{
let mut index: SymbolIndex = HashMap::new();
for file_ext in extractions {
let file_ext = file_ext.borrow();
if let Some(&file_id) = path_to_file_id.get(&file_ext.path) {
let entries: Vec<_> = file_ext
.symbols
.iter()
.map(|s| (s.id, s.name.clone(), s.language, s.visibility))
.collect();
index.entry(file_id).or_default().extend(entries);
}
}
index
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn empty_cache() {
let cache = FlattenedScopeCache {
scopes: HashMap::new(),
};
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert!(cache.resolve(FileId::new(1).unwrap(), "foo").is_none());
}
#[test]
fn detailed_resolution_keeps_each_use_site_and_the_triples_are_unchanged() {
use crate::model::{LineColumn, Symbol, SymbolKind, UnresolvedReference};
fn range(start: usize, end: usize) -> SourceRange {
SourceRange {
byte_start: start,
byte_end: end,
start: LineColumn {
line: 0,
column: start,
},
end: LineColumn {
line: 0,
column: end,
},
}
}
fn symbol(id: u32, name: &str, start: usize, end: usize, path: &std::path::Path) -> Symbol {
Symbol {
id: SymbolId::new(id).unwrap(),
name: name.to_string(),
kind: SymbolKind::Function,
language: LangId::Python,
file_path: path.to_path_buf(),
source_range: range(start, end),
name_range: None,
visibility: None,
signature: None,
docstring: None,
is_async: false,
}
}
let path = PathBuf::from("a.py");
let file_id = FileId::new(1).unwrap();
let mut file = FileExtraction::empty(path.clone(), LangId::Python);
file.symbols = vec![
symbol(1, "caller", 0, 100, &path),
symbol(2, "helper", 200, 210, &path),
];
file.references = vec![
UnresolvedReference {
name: "helper".into(),
range: range(10, 16),
},
UnresolvedReference {
name: "helper".into(),
range: range(30, 36),
},
];
let mut symbol_index: SymbolIndex = HashMap::new();
symbol_index.insert(
file_id,
vec![
(
SymbolId::new(1).unwrap(),
"caller".into(),
LangId::Python,
None,
),
(
SymbolId::new(2).unwrap(),
"helper".into(),
LangId::Python,
None,
),
],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::new(),
file_languages: HashMap::from([(file_id, LangId::Python)]),
file_paths: HashMap::from([(file_id, path.clone())]),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
let extractions = vec![file];
let paths = HashMap::from([(path, file_id)]);
let mut diagnostics = Vec::new();
let resolved = resolve_references_detailed(&extractions, &paths, &cache, &mut diagnostics);
assert!(diagnostics.is_empty());
assert_eq!(resolved.len(), 2, "one record per use site");
assert_eq!(resolved[0].range.byte_start, 10);
assert_eq!(resolved[1].range.byte_start, 30);
assert!(
resolved
.iter()
.all(|record| record.source == SymbolId::new(1).unwrap())
);
assert!(
resolved.iter().all(
|record| record.target == SymbolId::new(2).unwrap() && record.confidence == 1.0
)
);
let mut wrapper_diagnostics = Vec::new();
let triples =
resolve_all_references(&extractions, &paths, &cache, &mut wrapper_diagnostics);
assert!(wrapper_diagnostics.is_empty());
assert_eq!(
triples,
vec![(SymbolId::new(1).unwrap(), SymbolId::new(2).unwrap(), 1.0)],
"the wrapper max-merges the two use sites into one edge"
);
}
#[test]
fn scope_cache_resolve_own_file() {
let mut symbol_index: SymbolIndex = HashMap::new();
symbol_index.insert(
FileId::new(1).unwrap(),
vec![(
SymbolId::new(10).unwrap(),
"main".into(),
LangId::Python,
None,
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::new(),
file_languages: HashMap::from([(FileId::new(1).unwrap(), LangId::Python)]),
file_paths: HashMap::new(),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
let result = cache.resolve(FileId::new(1).unwrap(), "main");
assert!(result.is_some());
let matches = result.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].0, SymbolId::new(10).unwrap());
assert_eq!(matches[0].1, 1.0);
}
#[test]
fn scope_cache_resolve_imported_symbol() {
let mut symbol_index = HashMap::new();
symbol_index.insert(FileId::new(1).unwrap(), vec![]);
symbol_index.insert(
FileId::new(2).unwrap(),
vec![(
SymbolId::new(20).unwrap(),
"helper".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::from([(
FileId::new(1).unwrap(),
vec![FileId::new(2).unwrap()],
)]),
file_languages: HashMap::from([
(FileId::new(1).unwrap(), LangId::Python),
(FileId::new(2).unwrap(), LangId::Python),
]),
file_paths: HashMap::new(),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
let result = cache.resolve(FileId::new(1).unwrap(), "helper");
assert!(result.is_some());
let matches = result.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].0, SymbolId::new(20).unwrap());
assert_eq!(matches[0].1, 1.0);
}
#[test]
fn scope_cache_missing_symbol() {
let mut symbol_index = HashMap::new();
symbol_index.insert(
FileId::new(1).unwrap(),
vec![(
SymbolId::new(10).unwrap(),
"foo".into(),
LangId::Python,
None,
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::new(),
file_languages: HashMap::from([(FileId::new(1).unwrap(), LangId::Python)]),
file_paths: HashMap::new(),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
assert!(cache.resolve(FileId::new(1).unwrap(), "bar").is_none());
}
#[test]
fn scope_cache_cycle_safe() {
let mut symbol_index = HashMap::new();
symbol_index.insert(
FileId::new(1).unwrap(),
vec![(
SymbolId::new(10).unwrap(),
"a".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
symbol_index.insert(
FileId::new(2).unwrap(),
vec![(
SymbolId::new(20).unwrap(),
"b".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::from([
(FileId::new(1).unwrap(), vec![FileId::new(2).unwrap()]),
(FileId::new(2).unwrap(), vec![FileId::new(1).unwrap()]),
]),
file_languages: HashMap::from([
(FileId::new(1).unwrap(), LangId::Python),
(FileId::new(2).unwrap(), LangId::Python),
]),
file_paths: HashMap::new(),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
assert!(cache.resolve(FileId::new(1).unwrap(), "b").is_some());
assert!(cache.resolve(FileId::new(2).unwrap(), "a").is_some());
}
#[test]
fn scope_cache_cross_language_confidence() {
let mut symbol_index = HashMap::new();
symbol_index.insert(FileId::new(1).unwrap(), vec![]);
symbol_index.insert(
FileId::new(2).unwrap(),
vec![(
SymbolId::new(20).unwrap(),
"util".into(),
LangId::Rust,
Some(Visibility::Public),
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::from([(
FileId::new(1).unwrap(),
vec![FileId::new(2).unwrap()],
)]),
file_languages: HashMap::from([
(FileId::new(1).unwrap(), LangId::Python),
(FileId::new(2).unwrap(), LangId::Rust),
]),
file_paths: HashMap::new(),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
let result = cache.resolve(FileId::new(1).unwrap(), "util");
assert!(result.is_some());
assert_eq!(result.unwrap()[0].1, 0.6);
}
#[test]
fn resolve_references_creates_edges() {
use crate::model::{LineColumn, SourceRange, Symbol, SymbolKind, UnresolvedReference};
let sym_a = Symbol {
id: SymbolId::new(1).unwrap(),
name: "caller".into(),
kind: SymbolKind::Function,
language: LangId::Python,
file_path: PathBuf::from("/proj/a.py"),
source_range: SourceRange {
byte_start: 0,
byte_end: 50,
start: LineColumn { line: 0, column: 0 },
end: LineColumn { line: 2, column: 0 },
},
name_range: None,
visibility: None,
signature: None,
docstring: None,
is_async: false,
};
let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
file.symbols = vec![sym_a];
file.references = vec![UnresolvedReference {
name: "helper".into(),
range: SourceRange {
byte_start: 20,
byte_end: 26,
start: LineColumn { line: 1, column: 4 },
end: LineColumn {
line: 1,
column: 10,
},
},
}];
let mut path_to_file_id = HashMap::new();
path_to_file_id.insert(PathBuf::from("/proj/a.py"), FileId::new(1).unwrap());
let mut scopes: HashMap<FileId, ScopeMap> = HashMap::new();
let mut scope = HashMap::new();
scope.insert("helper".into(), vec![(SymbolId::new(99).unwrap(), 1.0)]);
scopes.insert(FileId::new(1).unwrap(), scope);
let cache = FlattenedScopeCache { scopes };
let edges = resolve_all_references(&[file], &path_to_file_id, &cache, &mut Vec::new());
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].0, SymbolId::new(1).unwrap());
assert_eq!(edges[0].1, SymbolId::new(99).unwrap());
assert_eq!(edges[0].2, 1.0);
}
#[test]
fn resolve_references_selects_innermost_enclosing_symbol_regardless_of_vector_order() {
use crate::model::{LineColumn, SourceRange, Symbol, SymbolKind, UnresolvedReference};
let inner_method = Symbol {
id: SymbolId::new(1).unwrap(),
name: "inner_method".into(),
kind: SymbolKind::Method,
language: LangId::Python,
file_path: PathBuf::from("/proj/a.py"),
source_range: SourceRange {
byte_start: 10,
byte_end: 50,
start: LineColumn { line: 1, column: 0 },
end: LineColumn { line: 3, column: 0 },
},
name_range: None,
visibility: None,
signature: None,
docstring: None,
is_async: false,
};
let outer_class = Symbol {
id: SymbolId::new(2).unwrap(),
name: "OuterClass".into(),
kind: SymbolKind::Class,
language: LangId::Python,
file_path: PathBuf::from("/proj/a.py"),
source_range: SourceRange {
byte_start: 0,
byte_end: 100,
start: LineColumn { line: 0, column: 0 },
end: LineColumn { line: 5, column: 0 },
},
name_range: None,
visibility: None,
signature: None,
docstring: None,
is_async: false,
};
let mut file = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
file.symbols = vec![inner_method, outer_class]; file.references = vec![UnresolvedReference {
name: "helper".into(),
range: SourceRange {
byte_start: 20,
byte_end: 26,
start: LineColumn { line: 2, column: 4 },
end: LineColumn {
line: 2,
column: 10,
},
},
}];
let mut path_to_file_id = HashMap::new();
path_to_file_id.insert(PathBuf::from("/proj/a.py"), FileId::new(1).unwrap());
let mut scopes: HashMap<FileId, ScopeMap> = HashMap::new();
let mut scope = HashMap::new();
scope.insert("helper".into(), vec![(SymbolId::new(99).unwrap(), 1.0)]);
scopes.insert(FileId::new(1).unwrap(), scope);
let cache = FlattenedScopeCache { scopes };
let edges = resolve_all_references(&[file], &path_to_file_id, &cache, &mut Vec::new());
assert_eq!(edges.len(), 1);
assert_eq!(
edges[0].0,
SymbolId::new(1).unwrap(),
"Reference should attach to innermost symbol SymbolId(1), but attached to SymbolId({})",
edges[0].0.to_raw()
);
assert_eq!(edges[0].1, SymbolId::new(99).unwrap());
}
#[test]
fn local_symbol_shadows_the_imported_symbol() {
let mut symbol_index: SymbolIndex = HashMap::new();
symbol_index.insert(
FileId::new(1).unwrap(),
vec![(
SymbolId::new(10).unwrap(),
"helper".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
symbol_index.insert(
FileId::new(2).unwrap(),
vec![(
SymbolId::new(20).unwrap(),
"helper".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::from([(
FileId::new(1).unwrap(),
vec![FileId::new(2).unwrap()],
)]),
file_languages: HashMap::from([
(FileId::new(1).unwrap(), LangId::Python),
(FileId::new(2).unwrap(), LangId::Python),
]),
file_paths: HashMap::from([
(FileId::new(1).unwrap(), PathBuf::from("/proj/main.py")),
(FileId::new(2).unwrap(), PathBuf::from("/proj/lib.py")),
]),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
let matches = cache.resolve(FileId::new(1).unwrap(), "helper").unwrap();
assert_eq!(
matches.len(),
1,
"the local definition must shadow the imported one"
);
assert_eq!(matches[0].0, SymbolId::new(10).unwrap());
assert_eq!(matches[0].1, 1.0);
}
#[test]
fn imported_candidates_are_ranked_by_path_not_by_id() {
let mut symbol_index: SymbolIndex = HashMap::new();
symbol_index.insert(FileId::new(1).unwrap(), vec![]);
symbol_index.insert(
FileId::new(2).unwrap(),
vec![(
SymbolId::new(30).unwrap(),
"util".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
symbol_index.insert(
FileId::new(3).unwrap(),
vec![(
SymbolId::new(20).unwrap(),
"util".into(),
LangId::Python,
Some(Visibility::Public),
)],
);
let ctx = ResolutionContext {
symbol_index,
import_adjacency: HashMap::from([(
FileId::new(1).unwrap(),
vec![FileId::new(2).unwrap(), FileId::new(3).unwrap()],
)]),
file_languages: HashMap::from([
(FileId::new(1).unwrap(), LangId::Python),
(FileId::new(2).unwrap(), LangId::Python),
(FileId::new(3).unwrap(), LangId::Python),
]),
file_paths: HashMap::from([
(FileId::new(1).unwrap(), PathBuf::from("/proj/app.py")),
(FileId::new(2).unwrap(), PathBuf::from("/proj/a_util.py")),
(FileId::new(3).unwrap(), PathBuf::from("/proj/z_util.py")),
]),
};
let cache = FlattenedScopeCache::build(&ctx, &mut Vec::new());
let matches = cache.resolve(FileId::new(1).unwrap(), "util").unwrap();
assert_eq!(
matches.len(),
2,
"an ambiguous import keeps both candidates"
);
assert_eq!(
matches[0].0,
SymbolId::new(30).unwrap(),
"candidate order must follow the file path, not the raw symbol id"
);
}
}