use super::*;
use crate::semantic::Binding;
pub(crate) fn document_symbols_via_db(
snapshot: &Analysis,
path: &Path,
buffer: &TextBuffer,
encoding: PositionEncoding,
) -> Vec<DocumentSymbol> {
with_document_semantics(snapshot, path, buffer, |root, model| {
document_symbols_from_model(root, model, buffer.line_index(), encoding)
})
}
pub fn compute_document_symbols(text: &str, encoding: PositionEncoding) -> Vec<DocumentSymbol> {
compute_document_symbols_in(&TextBuffer::from(text), encoding)
}
pub(crate) fn compute_document_symbols_in(
buffer: &TextBuffer,
encoding: PositionEncoding,
) -> Vec<DocumentSymbol> {
let text = buffer.text();
let root = parse(text).cst;
let model = SemanticModel::build(&root);
document_symbols_from_model(&root, &model, buffer.line_index(), encoding)
}
fn document_symbols_from_model(
root: &SyntaxNode,
model: &SemanticModel,
line_index: &LineIndex,
encoding: PositionEncoding,
) -> Vec<DocumentSymbol> {
let mut bindings: Vec<_> = model
.bindings()
.iter()
.filter(|b| matches!(b.kind, BindingKind::Local | BindingKind::Implicit))
.collect();
bindings.sort_unstable_by_key(|b| b.def_range.start());
let mut symbols = Vec::new();
collect_document_symbols(root, &bindings, line_index, &mut symbols, encoding);
symbols
}
fn collect_document_symbols(
node: &SyntaxNode,
mut bindings: &[&Binding],
line_index: &LineIndex,
out: &mut Vec<DocumentSymbol>,
encoding: PositionEncoding,
) {
for child in node.children() {
if bindings.is_empty() {
break;
}
let range = child.text_range();
let start = bindings.partition_point(|b| b.def_range.start() < range.start());
bindings = &bindings[start..];
let end = bindings.partition_point(|b| b.def_range.start() < range.end());
let (within, remaining) = bindings.split_at(end);
bindings = remaining;
if within.is_empty() {
continue;
}
match document_symbol_for(&child, within, line_index, encoding) {
Some(symbol) => out.push(symbol),
None => collect_document_symbols(&child, within, line_index, out, encoding),
}
}
}
#[expect(deprecated, reason = "DocumentSymbol::deprecated is a required field")]
fn document_symbol_for(
node: &SyntaxNode,
bindings: &[&Binding],
line_index: &LineIndex,
encoding: PositionEncoding,
) -> Option<DocumentSymbol> {
let assign = AssignmentExpr::cast(node.clone())?;
let name_token = assign.target_name_token()?;
let name_range = name_token.text_range();
let binding_index = bindings
.binary_search_by_key(&name_range.start(), |b| b.def_range.start())
.ok()?;
let binding = bindings[binding_index];
if binding.def_range != name_range {
return None;
}
let value = assign.value_element();
let is_function =
matches!(&value, Some(NodeOrToken::Node(n)) if FunctionExpr::can_cast(n.kind()));
let mut children = Vec::new();
if let Some(NodeOrToken::Node(value_node)) = &value {
collect_document_symbols(value_node, bindings, line_index, &mut children, encoding);
}
Some(DocumentSymbol {
name: binding.name.to_string(),
detail: None,
kind: if is_function {
LspSymbolKind::FUNCTION
} else {
LspSymbolKind::VARIABLE
},
tags: None,
deprecated: None,
range: text_range_to_lsp_range(line_index, node.text_range(), encoding),
selection_range: text_range_to_lsp_range(line_index, name_token.text_range(), encoding),
children: (!children.is_empty()).then_some(children),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_symbols_preserve_source_order_nesting_and_spans() {
let outer = "outer <- function(param = (default <- 1)) {\n\
for (i in 1:2) { if (param) `café` <<- i }\n\
x[idx <- 1] <- 2\n\
{ inner <- 3 } -> right\n\
\"名\" <- 4\n\
}";
let text = format!("#' 😀 Documentation.\nquote(hidden <- 0)\n{outer}\nlast <- 5\n");
let expected = [
(0, "outer", "outer", outer, LspSymbolKind::FUNCTION),
(
1,
"default",
"default",
"default <- 1",
LspSymbolKind::VARIABLE,
),
(
1,
"`café`",
"`café`",
"`café` <<- i",
LspSymbolKind::VARIABLE,
),
(1, "idx", "idx", "idx <- 1", LspSymbolKind::VARIABLE),
(
1,
"right",
"right",
"{ inner <- 3 } -> right",
LspSymbolKind::VARIABLE,
),
(2, "inner", "inner", "inner <- 3", LspSymbolKind::VARIABLE),
(1, "名", "\"名\"", "\"名\" <- 4", LspSymbolKind::VARIABLE),
(0, "last", "last", "last <- 5", LspSymbolKind::VARIABLE),
];
fn flatten<'a>(
symbols: &'a [DocumentSymbol],
depth: usize,
out: &mut Vec<(usize, &'a DocumentSymbol)>,
) {
for symbol in symbols {
out.push((depth, symbol));
if let Some(children) = &symbol.children {
flatten(children, depth + 1, out);
}
}
}
let index = LineIndex::new(&text);
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
let symbols = compute_document_symbols(&text, encoding);
let mut actual = Vec::new();
flatten(&symbols, 0, &mut actual);
assert_eq!(actual.len(), expected.len(), "{actual:#?}");
for ((depth, symbol), (want_depth, name, token, statement, kind)) in
actual.into_iter().zip(expected)
{
assert_eq!(
(depth, symbol.name.as_str(), symbol.kind),
(want_depth, name, kind)
);
let start = text.find(statement).unwrap();
let name_start = start + statement.rfind(token).unwrap();
assert_eq!(
symbol.range,
Range::new(
index.byte_to_position(start, encoding),
index.byte_to_position(start + statement.len(), encoding),
),
);
assert_eq!(
symbol.selection_range,
Range::new(
index.byte_to_position(name_start, encoding),
index.byte_to_position(name_start + token.len(), encoding),
),
);
}
}
}
#[test]
fn cached_document_symbols_match_fresh_analysis_after_edits() {
let mut db = IncrementalDatabase::default();
let file = db.upsert_file(test_path(), "old <- 0\n");
let _ = db.semantic_model(file);
db.set_roxygen_markdown(file, true);
for text in [
"#' A **documented** function.\n`f f` <- function(x) {\n y <- x\n if (x) z <- y\n}\n",
"label <- '😀'; 1 -> café\nf <- function(x) { x <<- 1; y <- x }\n",
"f <- function(x) { y <- x;\n",
"print(1)\n",
] {
let buffer = buf(text);
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
let expected = compute_document_symbols(text, encoding);
assert_eq!(
document_symbols_via_db(&db.snapshot(), test_path(), &buffer, encoding),
expected,
"stale cache",
);
assert_eq!(
document_symbols_via_db(
&IncrementalDatabase::default().snapshot(),
test_path(),
&buffer,
encoding,
),
expected,
"missing file",
);
}
db.set_file_text(file, buffer.text_arc());
let _ = db.semantic_model(file);
db.clear_query_log();
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
assert_eq!(
document_symbols_via_db(&db.snapshot(), test_path(), &buffer, encoding),
compute_document_symbols(text, encoding),
"warm cache",
);
}
assert!(db.query_log().is_empty());
}
}
}