use super::*;
#[derive(Debug, Clone)]
struct FnDef {
name: SmolStr,
selection: TextRange,
full: TextRange,
}
fn function_defs(root: &SyntaxNode, model: &SemanticModel) -> Vec<(FnDef, FunctionExpr)> {
let mut by_target: HashMap<TextRange, (SyntaxNode, FunctionExpr)> = HashMap::new();
for node in root.descendants() {
let Some(assign) = AssignmentExpr::cast(node.clone()) else {
continue;
};
let Some(name_token) = assign.target_name_token() else {
continue;
};
let Some(NodeOrToken::Node(value)) = assign.value_element() else {
continue;
};
let Some(func) = FunctionExpr::cast(value) else {
continue;
};
by_target.insert(name_token.text_range(), (node, func));
}
model
.bindings()
.iter()
.enumerate()
.filter(|(i, b)| {
matches!(b.kind, BindingKind::Local | BindingKind::Implicit)
&& model.binding_is_file_scope(BindingId::from_index(*i))
})
.filter_map(|(_, b)| {
let (assign, func) = by_target.get(&b.def_range)?;
Some((
FnDef {
name: b.name.clone(),
selection: b.def_range,
full: assign.text_range(),
},
func.clone(),
))
})
.collect()
}
fn fn_def_to_item(def: &FnDef, uri: &Uri, line_index: &LineIndex) -> CallHierarchyItem {
CallHierarchyItem {
name: def.name.to_string(),
kind: LspSymbolKind::FUNCTION,
tags: None,
detail: None,
uri: uri.clone(),
range: text_range_to_lsp_range(line_index, def.full),
selection_range: text_range_to_lsp_range(line_index, def.selection),
data: None,
}
}
fn function_item(snapshot: &Analysis, path: &Path, name: &str) -> Option<CallHierarchyItem> {
let file = snapshot.lookup_file(path)?;
let uri = uri::from_path(path)?;
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let (def, _) = function_defs(&root, model)
.into_iter()
.find(|(d, _)| d.name.as_str() == name)?;
let line_index = LineIndex::new(snapshot.file_text(file));
Some(fn_def_to_item(&def, &uri, &line_index))
}
pub(crate) fn prepare_call_hierarchy_via_db(
snapshot: &Analysis,
path: &Path,
uri: &Uri,
text: &str,
position: Position,
) -> Option<Vec<CallHierarchyItem>> {
let line_index = LineIndex::new(text);
let offset = TextSize::new(line_index.position_to_byte(position).min(text.len()) as u32);
let root = parse(text).cst;
let model = SemanticModel::build(&root);
if let Some(items) = prepare_local(&root, &model, offset, uri, &line_index) {
return Some(items);
}
let token = pick_name_token(&root, offset)?;
if token.kind() != SyntaxKind::IDENT
|| matches!(
symbol_query_at(&root, offset),
Some(SymbolQuery::Namespaced { .. })
)
{
return None;
}
let name = SmolStr::new(token.text());
let items = salsa::Cancelled::catch(AssertUnwindSafe(|| {
snapshot
.workspace_def_sites(&name)
.into_iter()
.filter(|(def_path, _)| def_path != path)
.filter_map(|(def_path, _)| function_item(snapshot, &def_path, &name))
.collect::<Vec<_>>()
}))
.unwrap_or_default();
(!items.is_empty()).then_some(items)
}
fn prepare_local(
root: &SyntaxNode,
model: &SemanticModel,
offset: TextSize,
uri: &Uri,
line_index: &LineIndex,
) -> Option<Vec<CallHierarchyItem>> {
let token = pick_name_token(root, offset)?;
if token.kind() != SyntaxKind::IDENT {
return None;
}
let range = token.text_range();
let defs = function_defs(root, model);
if let Some((def, _)) = defs.iter().find(|(d, _)| d.selection == range) {
return Some(vec![fn_def_to_item(def, uri, line_index)]);
}
let ident = model.idents().iter().find(|i| i.range == range)?;
let binding = model.resolve_local(ident)?;
if !model.binding_is_file_scope(binding) {
return Some(Vec::new());
}
let def_range = model.binding(binding).def_range;
Some(
defs.iter()
.find(|(d, _)| d.selection == def_range)
.map(|(d, _)| vec![fn_def_to_item(d, uri, line_index)])
.unwrap_or_default(),
)
}
pub(crate) fn incoming_calls_via_db(
snapshot: &Analysis,
item: &CallHierarchyItem,
) -> Option<Vec<CallHierarchyIncomingCall>> {
let path = uri::to_path(&item.uri)?;
let name = item.name.clone();
salsa::Cancelled::catch(AssertUnwindSafe(|| incoming_calls(snapshot, &path, &name)))
.ok()
.flatten()
}
fn incoming_calls(
snapshot: &Analysis,
def_path: &Path,
name: &str,
) -> Option<Vec<CallHierarchyIncomingCall>> {
let binding = snapshot.cross_file_binding(def_path, name);
let mut groups: Vec<IncomingGroup> = Vec::new();
for member in &binding.cohort {
collect_incoming(snapshot, member, name, false, &mut groups);
}
for reader in &binding.readers {
collect_incoming(snapshot, reader, name, true, &mut groups);
}
Some(
groups
.into_iter()
.map(|g| CallHierarchyIncomingCall {
from: g.from,
from_ranges: g.from_ranges,
})
.collect(),
)
}
struct IncomingGroup {
uri: Uri,
selection: TextRange,
from: CallHierarchyItem,
from_ranges: Vec<Range>,
}
fn collect_incoming(
snapshot: &Analysis,
file_path: &Path,
name: &str,
reader: bool,
groups: &mut Vec<IncomingGroup>,
) {
let Some(file) = snapshot.lookup_file(file_path) else {
return;
};
let Some(uri) = uri::from_path(file_path) else {
return;
};
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let line_index = LineIndex::new(snapshot.file_text(file));
let defs = function_defs(&root, model);
let ref_ranges: Vec<TextRange> = if reader {
snapshot.read_ranges_in(file, name)
} else {
file_scope_occurrences_in(model, name)
.map(|(_, reads)| reads)
.unwrap_or_default()
};
for range in ref_ranges {
if call_at_callee(&root, range).is_none() {
continue;
}
let Some(caller) = enclosing_top_level_function(&root, &defs, range) else {
continue; };
let from_range = text_range_to_lsp_range(&line_index, range);
match groups
.iter_mut()
.find(|g| g.uri == uri && g.selection == caller.selection)
{
Some(group) => group.from_ranges.push(from_range),
None => groups.push(IncomingGroup {
uri: uri.clone(),
selection: caller.selection,
from: fn_def_to_item(&caller, &uri, &line_index),
from_ranges: vec![from_range],
}),
}
}
}
pub(crate) fn outgoing_calls_via_db(
snapshot: &Analysis,
item: &CallHierarchyItem,
) -> Option<Vec<CallHierarchyOutgoingCall>> {
let path = uri::to_path(&item.uri)?;
let name = item.name.clone();
salsa::Cancelled::catch(AssertUnwindSafe(|| outgoing_calls(snapshot, &path, &name)))
.ok()
.flatten()
}
struct OutgoingGroup {
path: PathBuf,
name: SmolStr,
from_ranges: Vec<TextRange>,
}
fn outgoing_calls(
snapshot: &Analysis,
path: &Path,
name: &str,
) -> Option<Vec<CallHierarchyOutgoingCall>> {
let file = snapshot.lookup_file(path)?;
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let line_index = LineIndex::new(snapshot.file_text(file));
let defs = function_defs(&root, model);
let func = defs
.iter()
.find(|(d, _)| d.name.as_str() == name)
.map(|(_, f)| f.clone())?;
let local_fn_names: HashSet<&str> = defs.iter().map(|(d, _)| d.name.as_str()).collect();
let Some(NodeOrToken::Node(body)) = func.body() else {
return Some(Vec::new());
};
let mut groups: Vec<OutgoingGroup> = Vec::new();
for call_node in body.descendants() {
if call_node.kind() != SyntaxKind::CALL_EXPR {
continue;
}
let Some(call) = CallExpr::cast(call_node.clone()) else {
continue;
};
let Some(callee) = call.callee_token() else {
continue; };
if callee.kind() != SyntaxKind::IDENT {
continue;
}
if call_node
.parent()
.and_then(BinaryExpr::cast)
.and_then(|b| b.namespace_access())
.is_some()
{
continue;
}
let callee_name = SmolStr::new(callee.text());
let Some(target) = resolve_callee(snapshot, path, &local_fn_names, &callee_name) else {
continue;
};
let range = callee.text_range();
match groups
.iter_mut()
.find(|g| g.path == target && g.name == callee_name)
{
Some(group) => group.from_ranges.push(range),
None => groups.push(OutgoingGroup {
path: target,
name: callee_name,
from_ranges: vec![range],
}),
}
}
Some(
groups
.into_iter()
.filter_map(|g| {
let to = function_item(snapshot, &g.path, &g.name)?;
Some(CallHierarchyOutgoingCall {
to,
from_ranges: g
.from_ranges
.iter()
.map(|r| text_range_to_lsp_range(&line_index, *r))
.collect(),
})
})
.collect(),
)
}
fn resolve_callee(
snapshot: &Analysis,
from_path: &Path,
local_fn_names: &HashSet<&str>,
callee_name: &str,
) -> Option<PathBuf> {
if local_fn_names.contains(callee_name) {
return Some(from_path.to_path_buf());
}
snapshot
.visible_def_files(from_path, callee_name)
.into_iter()
.find(|p| function_item(snapshot, p, callee_name).is_some())
}
fn call_at_callee(root: &SyntaxNode, range: TextRange) -> Option<SyntaxNode> {
let token = match root.token_at_offset(range.start()) {
TokenAtOffset::None => return None,
TokenAtOffset::Single(t) => t,
TokenAtOffset::Between(left, right) => {
if left.text_range() == range {
left
} else {
right
}
}
};
let call = token.parent()?;
if call.kind() != SyntaxKind::CALL_EXPR {
return None;
}
if CallExpr::cast(call.clone())?.callee_token()?.text_range() != range {
return None;
}
if call
.parent()
.and_then(BinaryExpr::cast)
.and_then(|b| b.namespace_access())
.is_some()
{
return None;
}
Some(call)
}
fn enclosing_top_level_function(
root: &SyntaxNode,
defs: &[(FnDef, FunctionExpr)],
range: TextRange,
) -> Option<FnDef> {
let stmt = root
.children()
.find(|c| c.text_range().contains_range(range))?;
defs.iter()
.find(|(d, _)| d.full == stmt.text_range())
.map(|(d, _)| d.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn prepare_at(
snapshot: &Analysis,
path: &Path,
text: &str,
offset: usize,
) -> Vec<CallHierarchyItem> {
let uri = uri::from_path(path).unwrap();
prepare_call_hierarchy_via_db(snapshot, path, &uri, text, pos_at(text, offset))
.unwrap_or_default()
}
fn item_named(snapshot: &Analysis, path: &Path, name: &str) -> CallHierarchyItem {
function_item(snapshot, path, name).expect("function item")
}
#[test]
fn prepare_on_a_definition_yields_its_item() {
let src = "foo <- function() 1\n";
let snapshot = rename_workspace(src, "");
let items = prepare_at(&snapshot, &ws_path("a.R"), src, src.find("foo").unwrap());
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "foo");
assert_eq!(items[0].kind, LspSymbolKind::FUNCTION);
}
#[test]
fn prepare_on_a_call_site_yields_the_callee_item() {
let src = "foo <- function() 1\nbar <- function() foo()\n";
let snapshot = rename_workspace(src, "");
let offset = src.find("foo()").unwrap();
let items = prepare_at(&snapshot, &ws_path("a.R"), src, offset);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "foo");
}
#[test]
fn prepare_declines_a_non_function_binding() {
let src = "x <- 1\nprint(x)\n";
let snapshot = rename_workspace(src, "");
let items = prepare_at(&snapshot, &ws_path("a.R"), src, src.find("x <-").unwrap());
assert!(items.is_empty());
}
#[test]
fn prepare_resolves_a_cross_file_callee() {
let a_src = "foo <- function() 1\n";
let b_src = "source(\"a.R\")\nbar <- function() foo()\n";
let snapshot = rename_workspace(a_src, b_src);
let items = prepare_at(
&snapshot,
&ws_path("b.R"),
b_src,
b_src.find("foo()").unwrap(),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "foo");
assert_eq!(items[0].uri, uri::from_path(&ws_path("a.R")).unwrap());
}
#[test]
fn outgoing_collects_intra_file_calls() {
let src = "helper <- function() 1\nmain <- function() {\n helper()\n helper()\n}\n";
let snapshot = rename_workspace(src, "");
let calls =
outgoing_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "main"))
.expect("outgoing");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].to.name, "helper");
assert_eq!(calls[0].from_ranges.len(), 2, "both call sites reported");
}
#[test]
fn outgoing_skips_namespaced_and_unresolved_calls() {
let src = "main <- function() {\n dplyr::filter(x)\n undefined_fn()\n}\n";
let snapshot = rename_workspace(src, "");
let calls =
outgoing_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "main"))
.expect("outgoing");
assert!(calls.is_empty(), "no top-level function callee resolves");
}
#[test]
fn outgoing_resolves_a_cross_file_callee() {
let a_src = "foo <- function() 1\n";
let b_src = "source(\"a.R\")\nbar <- function() foo()\n";
let snapshot = rename_workspace(a_src, b_src);
let calls =
outgoing_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("b.R"), "bar"))
.expect("outgoing");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].to.name, "foo");
assert_eq!(calls[0].to.uri, uri::from_path(&ws_path("a.R")).unwrap());
}
#[test]
fn incoming_finds_callers_across_a_source_edge() {
let a_src = "foo <- function() 1\n";
let b_src = "source(\"a.R\")\nbar <- function() foo()\n";
let snapshot = rename_workspace(a_src, b_src);
let calls =
incoming_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "foo"))
.expect("incoming");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].from.name, "bar");
assert_eq!(calls[0].from.uri, uri::from_path(&ws_path("b.R")).unwrap());
assert_eq!(calls[0].from_ranges.len(), 1);
}
#[test]
fn incoming_drops_script_level_calls() {
let src = "foo <- function() 1\nfoo()\n";
let snapshot = rename_workspace(src, "");
let calls =
incoming_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "foo"))
.expect("incoming");
assert!(calls.is_empty(), "script-level call site is dropped in v1");
}
#[test]
fn incoming_attributes_a_nested_call_to_the_top_level_function() {
let src = "foo <- function() 1\nouter <- function() {\n inner <- function() foo()\n inner()\n}\n";
let snapshot = rename_workspace(src, "");
let calls =
incoming_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "foo"))
.expect("incoming");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].from.name, "outer");
}
#[test]
fn incoming_excludes_a_value_use() {
let src = "foo <- function() 1\nbar <- function() lapply(xs, foo)\n";
let snapshot = rename_workspace(src, "");
let calls =
incoming_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "foo"))
.expect("incoming");
assert!(calls.is_empty(), "a value use is not a call");
}
#[test]
fn incoming_excludes_a_disjoint_same_name_def() {
let a_src = "foo <- function() 1\n";
let b_src = "foo <- function() 2\nbar <- function() foo()\n";
let snapshot = rename_workspace(a_src, b_src);
let calls =
incoming_calls_via_db(&snapshot, &item_named(&snapshot, &ws_path("a.R"), "foo"))
.expect("incoming");
assert!(
calls.is_empty(),
"b.R's foo is a disjoint binding; a.R's foo has no callers"
);
}
}