use crate::Symbol;
use crate::indexing::facade::IndexFacade;
pub enum SymbolResolution {
Resolved {
symbol: Symbol,
identifier: String,
},
NotFoundById(u32),
NotFoundByName(String),
Ambiguous {
name: String,
candidates: Vec<Symbol>,
},
MissingParam,
}
pub fn resolve_symbol_or_id(
facade: &IndexFacade,
symbol_id: Option<u32>,
name: Option<String>,
) -> SymbolResolution {
if let Some(id) = symbol_id {
match facade.get_symbol(crate::SymbolId(id)) {
Some(symbol) => SymbolResolution::Resolved {
symbol,
identifier: format!("symbol_id:{id}"),
},
None => SymbolResolution::NotFoundById(id),
}
} else if let Some(name) = name {
let mut symbols = facade.find_symbols_by_name(&name, None);
if symbols.is_empty() {
symbols = find_dotted_members(&name, |n| facade.find_symbols_by_name(n, None));
}
match symbols.len() {
0 => SymbolResolution::NotFoundByName(name),
1 => SymbolResolution::Resolved {
symbol: symbols.pop().expect("len checked"),
identifier: name,
},
_ => SymbolResolution::Ambiguous {
name,
candidates: symbols,
},
}
} else {
SymbolResolution::MissingParam
}
}
pub fn find_dotted_members(name: &str, find: impl Fn(&str) -> Vec<Symbol>) -> Vec<Symbol> {
let Some((class, member)) = name.rsplit_once('.') else {
return Vec::new();
};
if class.is_empty() || member.is_empty() {
return Vec::new();
}
find(member)
.into_iter()
.filter(|sym| is_member_of(sym, class))
.collect()
}
fn is_member_of(sym: &Symbol, class: &str) -> bool {
if let Some(crate::symbol::ScopeContext::ClassMember {
class_name: Some(c),
}) = &sym.scope_context
{
if c.as_ref() == class || c.rsplit('.').next() == Some(class) {
return true;
}
}
matches!(
sym.kind,
crate::SymbolKind::Method | crate::SymbolKind::Field | crate::SymbolKind::Constant
) && sym.module_path.as_deref().is_some_and(|mp| {
mp.strip_suffix(class)
.is_some_and(|rest| rest.ends_with("::") || rest.ends_with('.'))
})
}
pub fn render_ambiguity(tool: &str, name: &str, candidates: &[Symbol]) -> String {
let mut msg = format!(
"Ambiguous: found {} symbol(s) named '{}':\n",
candidates.len(),
name
);
for (i, sym) in candidates.iter().take(10).enumerate() {
msg.push_str(&format!(
" {}. symbol_id:{} - {:?} at {}:{}\n",
i + 1,
sym.id.value(),
sym.kind,
sym.file_path,
sym.range.start_line + 1
));
}
if candidates.len() > 10 {
msg.push_str(&format!(" ... and {} more\n", candidates.len() - 10));
}
msg.push_str(&format!("\nUse: {tool} symbol_id:<id> for specific symbol"));
msg
}
pub fn parse_receiver_context(context: &str) -> Option<(&str, bool)> {
if !(context.contains("receiver:") && context.contains("static:")) {
return None;
}
let mut receiver = "";
let mut is_static = false;
for part in context.split(',') {
if let Some(r) = part.strip_prefix("receiver:") {
receiver = r;
} else if let Some(s) = part.strip_prefix("static:") {
is_static = s == "true";
}
}
if receiver.is_empty() {
None
} else {
Some((receiver, is_static))
}
}
pub fn qualified_call(receiver: &str, is_static: bool, name: &str) -> String {
if is_static {
format!("{receiver}::{name}")
} else {
format!("{receiver}.{name}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn receiver_context_parses_both_forms() {
assert_eq!(
parse_receiver_context("receiver:Foo,static:true"),
Some(("Foo", true))
);
assert_eq!(
parse_receiver_context("receiver:bar,static:false"),
Some(("bar", false))
);
assert_eq!(parse_receiver_context("receiver:,static:true"), None);
assert_eq!(parse_receiver_context("unrelated context"), None);
}
#[test]
fn qualified_call_separator_follows_static_flag() {
assert_eq!(qualified_call("Foo", true, "new"), "Foo::new");
assert_eq!(qualified_call("foo", false, "run"), "foo.run");
}
fn method_symbol(id: u32, name: &str, class: Option<&str>, module_path: &str) -> Symbol {
let mut sym = Symbol::new(
crate::SymbolId::new(id).unwrap(),
name,
crate::SymbolKind::Method,
crate::FileId::new(1).unwrap(),
crate::Range::new(1, 0, 1, 10),
);
sym.scope_context = Some(crate::symbol::ScopeContext::ClassMember {
class_name: class.map(Into::into),
});
sym.module_path = Some(module_path.into());
sym
}
#[test]
fn dotted_lookup_filters_by_class_member() {
let a = method_symbol(1, "model_dump", Some("BaseModel"), "pydantic.main");
let b = method_symbol(2, "model_dump", Some("RootModel"), "pydantic.root_model");
let found = find_dotted_members("BaseModel.model_dump", |n| {
if n == "model_dump" {
vec![a.clone(), b.clone()]
} else {
vec![]
}
});
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, a.id);
}
#[test]
fn dotted_lookup_matches_module_path_suffix() {
let mut sym = method_symbol(1, "new", None, "crate::types::RawSymbol");
sym.scope_context = None;
let found = find_dotted_members("RawSymbol.new", |n| {
if n == "new" {
vec![sym.clone()]
} else {
vec![]
}
});
assert_eq!(found.len(), 1);
}
#[test]
fn dotted_lookup_rejects_module_scoped_symbols() {
let mut sym = method_symbol(1, "Button", None, "src.components");
sym.scope_context = None;
sym.kind = crate::SymbolKind::Class;
let found = find_dotted_members("components.Button", |n| {
if n == "Button" {
vec![sym.clone()]
} else {
vec![]
}
});
assert!(found.is_empty());
}
#[test]
fn dotted_lookup_ignores_undotted_and_empty_segments() {
assert!(find_dotted_members("plain", |_| unreachable!("no dot, no lookup")).is_empty());
assert!(find_dotted_members(".x", |_| Vec::new()).is_empty());
assert!(find_dotted_members("x.", |_| Vec::new()).is_empty());
}
#[test]
fn symbol_kind_vocabulary_is_complete() {
use crate::types::SymbolKind;
for (input, expected) in [
("class", SymbolKind::Class),
("enum", SymbolKind::Enum),
("interface", SymbolKind::Interface),
("variable", SymbolKind::Variable),
("typealias", SymbolKind::TypeAlias),
("Function", SymbolKind::Function),
] {
assert_eq!(input.parse::<SymbolKind>().unwrap(), expected);
}
assert!("widget".parse::<SymbolKind>().is_err());
}
}