use std::collections::HashMap;
use std::path::Path;
use petgraph::stable_graph::NodeIndex;
use crate::symgraph::symbol::detect_language;
pub(crate) fn qualified_key(file: &str, symbol: &str) -> String {
format!("{file}::{symbol}")
}
pub(crate) fn bare_name(symbol: &str) -> &str {
symbol.rsplit("::").next().unwrap_or(symbol)
}
fn extension(file: &str) -> &str {
let base = file.rsplit('/').next().unwrap_or(file);
base.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("")
}
fn language_of(file: &str) -> &str {
detect_language(Path::new(file))
.map(|(_, tag)| tag)
.unwrap_or_else(|| extension(file))
}
fn path_suffix_matches(file: &str, path: &str) -> bool {
file == path || (file.ends_with(path) && file[..file.len() - path.len()].ends_with('/'))
}
fn scopes(file: &str) -> Vec<&str> {
if file.is_empty() {
return vec![""];
}
let mut out = vec![file];
let mut cur = file;
while let Some((parent, _)) = cur.rsplit_once('/') {
if parent.is_empty() {
break;
}
out.push(parent);
cur = parent;
}
out.push("");
out
}
fn in_scope(candidate: &str, scope: &str) -> bool {
if scope.is_empty() {
return true;
}
candidate == scope
|| (candidate.starts_with(scope) && candidate[scope.len()..].starts_with('/'))
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Candidate {
pub idx: NodeIndex,
pub callable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Grounds {
SameFile,
SharedScope,
CorpusUnique,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct NameIndex {
pub by_key: HashMap<String, Option<Candidate>>,
pub by_name: HashMap<String, Vec<Candidate>>,
pub file_of: HashMap<NodeIndex, String>,
}
impl NameIndex {
pub fn insert(&mut self, file: &str, symbol: &str, idx: NodeIndex, callable: bool) {
self.file_of.insert(idx, file.to_string());
let cand = Candidate { idx, callable };
self.by_key
.entry(qualified_key(file, symbol))
.and_modify(|slot| {
if slot.map(|c| c.idx) != Some(idx) {
*slot = None;
}
})
.or_insert(Some(cand));
self.by_name
.entry(symbol.to_string())
.or_default()
.push(cand);
let bare = bare_name(symbol);
if bare != symbol {
self.by_name.entry(bare.to_string()).or_default().push(cand);
}
}
pub fn candidates(&self, name: &str) -> &[Candidate] {
self.by_name.get(name).map_or(&[], |v| v.as_slice())
}
fn file_of(&self, idx: NodeIndex) -> &str {
self.file_of.get(&idx).map_or("", |f| f.as_str())
}
}
pub(crate) fn resolve_callee(
index: &NameIndex,
caller_file: &str,
callee: &str,
require_callable: bool,
) -> Option<(NodeIndex, Grounds)> {
if let Some(Some(c)) = index.by_key.get(&qualified_key(caller_file, callee))
&& (!require_callable || c.callable)
{
return Some((c.idx, Grounds::SameFile));
}
let mut named = index.candidates(callee);
if named.is_empty() {
named = index.candidates(bare_name(callee));
}
let caller_lang = language_of(caller_file);
let cands: Vec<&Candidate> = named
.iter()
.filter(|c| !require_callable || c.callable)
.filter(|c| caller_file.is_empty() || language_of(index.file_of(c.idx)) == caller_lang)
.collect();
if cands.is_empty() {
return None;
}
for scope in scopes(caller_file) {
let mut hit = None;
let mut n = 0usize;
for c in &cands {
if in_scope(index.file_of(c.idx), scope) {
n += 1;
hit = Some(c.idx);
}
}
if n > 1 {
return None;
}
if n == 1 {
let idx = hit?;
let grounds = if index.file_of(idx) == caller_file && !caller_file.is_empty() {
Grounds::SameFile
} else if scope.is_empty() {
Grounds::CorpusUnique
} else {
Grounds::SharedScope
};
return Some((idx, grounds));
}
}
None
}
pub(crate) fn rank_matches(
index: &NameIndex,
name: &str,
rank: impl Fn(NodeIndex) -> usize,
) -> Vec<NodeIndex> {
if let Some(Some(c)) = index.by_key.get(name) {
return vec![c.idx];
}
let mut hits: Vec<NodeIndex> = Vec::new();
if let Some((path, symbol)) = name.rsplit_once("::")
&& (path.contains('/') || path.contains('.'))
{
hits = index
.candidates(symbol)
.iter()
.filter(|c| path_suffix_matches(index.file_of(c.idx), path))
.map(|c| c.idx)
.collect();
}
if hits.is_empty() {
hits = index.candidates(name).iter().map(|c| c.idx).collect();
}
if hits.is_empty() {
hits = index
.candidates(bare_name(name))
.iter()
.map(|c| c.idx)
.collect();
}
hits.sort_by_key(|&i| (std::cmp::Reverse(rank(i)), i.index()));
hits.dedup();
hits
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_exact_key_shared_by_two_symbols_is_not_grounds() {
let mut index = NameIndex::default();
let func = NodeIndex::new(0);
let import = NodeIndex::new(1);
index.insert("src/io.rs", "write", func, true);
index.insert("src/io.rs", "write", import, false);
let (idx, grounds) =
resolve_callee(&index, "src/io.rs", "write", true).expect("callable twin resolves");
assert_eq!(idx, func);
assert_eq!(grounds, Grounds::SameFile);
assert!(resolve_callee(&index, "src/io.rs", "write", false).is_none());
}
#[test]
fn sibling_extensions_of_one_language_stay_ambiguous() {
let mut index = NameIndex::default();
index.insert("ui/lib/a.ts", "get", NodeIndex::new(0), true);
index.insert("ui/widgets/b.tsx", "get", NodeIndex::new(1), true);
assert!(resolve_callee(&index, "ui/app/main.ts", "get", true).is_none());
assert!(resolve_callee(&index, "crates/a/src/lib.rs", "get", true).is_none());
}
}