use std::panic::AssertUnwindSafe;
use std::path::Path;
use lsp_types::{DocumentSymbol, Range, SymbolKind};
use crate::incremental::Analysis;
use crate::parser::parse;
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
use crate::text::{LineIndex, PositionEncoding};
pub fn compute_document_symbols(text: &str, encoding: PositionEncoding) -> Vec<DocumentSymbol> {
let root = parse(text).cst;
symbols_for_tree(&root, text, encoding)
}
pub(crate) fn document_symbols_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
encoding: PositionEncoding,
) -> Vec<DocumentSymbol> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
let root = snapshot.parsed_tree(file);
Some(symbols_for_tree(&root, text, encoding))
}));
match cached {
Ok(Some(symbols)) => symbols,
Ok(None) | Err(_) => compute_document_symbols(text, encoding),
}
}
fn symbols_for_tree(
root: &SyntaxNode,
text: &str,
encoding: PositionEncoding,
) -> Vec<DocumentSymbol> {
let ctx = Ctx {
text,
line_index: LineIndex::new(text),
encoding,
};
let mut out = Vec::new();
collect_symbols(root, &ctx, &mut out);
out
}
struct Ctx<'a> {
text: &'a str,
line_index: LineIndex<'a>,
encoding: PositionEncoding,
}
impl Ctx<'_> {
fn lsp_range(&self, range: rowan::TextRange) -> Range {
Range::new(
self.line_index
.byte_to_position(range.start().into(), self.encoding),
self.line_index
.byte_to_position(range.end().into(), self.encoding),
)
}
}
fn collect_symbols(node: &SyntaxNode, ctx: &Ctx<'_>, out: &mut Vec<DocumentSymbol>) {
for child in node.children() {
visit(&child, ctx, out);
}
}
fn visit(node: &SyntaxNode, ctx: &Ctx<'_>, out: &mut Vec<DocumentSymbol>) {
let symbol = match node.kind() {
SyntaxKind::MODULE_DEF
| SyntaxKind::FUNCTION_DEF
| SyntaxKind::MACRO_DEF
| SyntaxKind::STRUCT_DEF
| SyntaxKind::ABSTRACT_DEF
| SyntaxKind::PRIMITIVE_DEF => def_symbol(node, ctx),
SyntaxKind::CONST_STMT => {
if const_symbols(node, ctx, out) {
return;
}
None
}
SyntaxKind::ASSIGNMENT_EXPR => short_form_symbol(node, ctx),
_ => None,
};
match symbol {
Some(symbol) => out.push(symbol),
None => collect_symbols(node, ctx, out),
}
}
fn def_symbol(def: &SyntaxNode, ctx: &Ctx<'_>) -> Option<DocumentSymbol> {
let sig_expr = signature_expr(def)?;
let head = unwrap_head(sig_expr.clone());
let function_like = matches!(def.kind(), SyntaxKind::FUNCTION_DEF | SyntaxKind::MACRO_DEF);
let (mut name, selection) = if function_like && head.kind() == SyntaxKind::CALL_EXPR {
callee_name(&head)?
} else {
head_name(&head)?
};
if def.kind() == SyntaxKind::MACRO_DEF {
name.insert(0, '@');
}
let detail = function_like
.then(|| signature_detail(&sig_expr, selection, ctx))
.flatten();
let kind = match def.kind() {
SyntaxKind::MODULE_DEF => SymbolKind::MODULE,
SyntaxKind::STRUCT_DEF | SyntaxKind::PRIMITIVE_DEF => SymbolKind::STRUCT,
SyntaxKind::ABSTRACT_DEF => SymbolKind::INTERFACE,
_ => SymbolKind::FUNCTION,
};
let mut children = Vec::new();
if let Some(block) = child_of_kind(def, SyntaxKind::BLOCK) {
if def.kind() == SyntaxKind::STRUCT_DEF {
collect_struct_members(&block, ctx, &mut children);
} else {
collect_symbols(&block, ctx, &mut children);
}
}
Some(make_symbol(
name,
detail,
kind,
def.text_range(),
selection,
children,
ctx,
))
}
fn short_form_symbol(assign: &SyntaxNode, ctx: &Ctx<'_>) -> Option<DocumentSymbol> {
if op_token(assign)?.kind() != SyntaxKind::EQ {
return None;
}
let lhs = assign.children().next()?;
let head = unwrap_head(lhs.clone());
if head.kind() != SyntaxKind::CALL_EXPR {
return None;
}
let (name, selection) = callee_name(&head)?;
let detail = signature_detail(&lhs, selection, ctx);
let mut children = Vec::new();
for rhs in assign.children().skip(1) {
visit(&rhs, ctx, &mut children);
}
Some(make_symbol(
name,
detail,
SymbolKind::FUNCTION,
assign.text_range(),
selection,
children,
ctx,
))
}
fn const_symbols(stmt: &SyntaxNode, ctx: &Ctx<'_>, out: &mut Vec<DocumentSymbol>) -> bool {
let inner = match stmt.children().next() {
Some(node) if node.kind() == SyntaxKind::GLOBAL_STMT => node.children().next(),
node => node,
};
let Some(inner) = inner else {
return false;
};
let target = if inner.kind() == SyntaxKind::ASSIGNMENT_EXPR {
match inner.children().next() {
Some(lhs) => lhs,
None => return false,
}
} else {
inner
};
let names: Vec<_> = if target.kind() == SyntaxKind::BARE_TUPLE_EXPR {
target
.children()
.filter_map(|item| head_name(&unwrap_head(item)))
.collect()
} else {
head_name(&unwrap_head(target)).into_iter().collect()
};
let emitted = !names.is_empty();
for (name, selection) in names {
out.push(make_symbol(
name,
None,
SymbolKind::CONSTANT,
stmt.text_range(),
selection,
Vec::new(),
ctx,
));
}
emitted
}
fn collect_struct_members(block: &SyntaxNode, ctx: &Ctx<'_>, out: &mut Vec<DocumentSymbol>) {
for child in block.children() {
match child.kind() {
SyntaxKind::NAME | SyntaxKind::TYPE_ANNOTATION => {
field_symbol(&child, &child, ctx, out);
}
SyntaxKind::CONST_STMT => {
if let Some(inner) = child.children().next() {
field_symbol(&child, &inner, ctx, out);
}
}
SyntaxKind::ASSIGNMENT_EXPR => {
if let Some(symbol) = short_form_symbol(&child, ctx) {
out.push(symbol);
} else if let Some(lhs) = child.children().next() {
field_symbol(&child, &lhs, ctx, out);
}
}
_ => visit(&child, ctx, out),
}
}
}
fn field_symbol(
full: &SyntaxNode,
target: &SyntaxNode,
ctx: &Ctx<'_>,
out: &mut Vec<DocumentSymbol>,
) {
if !matches!(
target.kind(),
SyntaxKind::NAME | SyntaxKind::TYPE_ANNOTATION | SyntaxKind::NONSTANDARD_IDENTIFIER
) {
return;
}
if let Some((name, selection)) = head_name(&unwrap_head(target.clone())) {
out.push(make_symbol(
name,
None,
SymbolKind::FIELD,
full.text_range(),
selection,
Vec::new(),
ctx,
));
}
}
fn signature_expr(def: &SyntaxNode) -> Option<SyntaxNode> {
child_of_kind(def, SyntaxKind::SIGNATURE)?.children().next()
}
fn unwrap_head(mut node: SyntaxNode) -> SyntaxNode {
while matches!(
node.kind(),
SyntaxKind::WHERE_EXPR | SyntaxKind::TYPE_ANNOTATION
) {
match node.first_child() {
Some(inner) => node = inner,
None => break,
}
}
node
}
fn callee_name(call: &SyntaxNode) -> Option<(String, rowan::TextRange)> {
let callee = call
.children_with_tokens()
.find(|el| !is_trivia(el.kind()))?;
match callee {
rowan::NodeOrToken::Node(node) => head_name(&node),
rowan::NodeOrToken::Token(token) => Some((token.text().to_string(), token.text_range())),
}
}
fn head_name(node: &SyntaxNode) -> Option<(String, rowan::TextRange)> {
match node.kind() {
SyntaxKind::NAME => {
let ident = node
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|t| !is_trivia(t.kind()))?;
Some((ident.text().to_string(), node.text_range()))
}
SyntaxKind::NONSTANDARD_IDENTIFIER => {
let content = node
.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|t| t.kind() == SyntaxKind::STRING_CONTENT)?;
Some((content.text().to_string(), node.text_range()))
}
SyntaxKind::BINARY_EXPR => {
let op = op_token(node)?;
if op.kind() == SyntaxKind::DOT {
let text: String = node
.descendants_with_tokens()
.filter_map(|el| el.into_token())
.filter(|t| !is_trivia(t.kind()))
.map(|t| t.text().to_string())
.collect();
Some((text, node.text_range()))
} else {
head_name(&node.first_child()?)
}
}
SyntaxKind::CURLY_EXPR => head_name(&node.first_child()?),
_ => None,
}
}
fn signature_detail(
sig_expr: &SyntaxNode,
name_range: rowan::TextRange,
ctx: &Ctx<'_>,
) -> Option<String> {
let start = usize::from(name_range.end());
let end = usize::from(sig_expr.text_range().end());
let detail = ctx.text.get(start..end)?.trim();
(!detail.is_empty()).then(|| detail.to_string())
}
fn op_token(node: &SyntaxNode) -> Option<SyntaxToken> {
let first_child_end = node.first_child()?.text_range().end();
node.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|t| t.text_range().start() >= first_child_end && !is_trivia(t.kind()))
}
fn child_of_kind(node: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxNode> {
node.children().find(|c| c.kind() == kind)
}
fn is_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::COMMENT
| SyntaxKind::BLOCK_COMMENT
)
}
#[expect(deprecated, reason = "DocumentSymbol::deprecated is a required field")]
fn make_symbol(
name: String,
detail: Option<String>,
kind: SymbolKind,
range: rowan::TextRange,
selection: rowan::TextRange,
children: Vec<DocumentSymbol>,
ctx: &Ctx<'_>,
) -> DocumentSymbol {
DocumentSymbol {
name,
detail,
kind,
tags: None,
deprecated: None,
range: ctx.lsp_range(range),
selection_range: ctx.lsp_range(selection),
children: (!children.is_empty()).then_some(children),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
#[test]
fn symbols_via_db_match_compute_and_fall_back() {
let encoding = PositionEncoding::Utf16;
let path = Path::new("/work/a.jl");
let buffer = "f(x) = x\n";
let expected = compute_document_symbols(buffer, encoding);
assert_eq!(expected.len(), 1, "fixture must yield a symbol");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, buffer.to_string());
assert_eq!(
document_symbols_via_db(&db.snapshot(), path, buffer, encoding),
expected,
"cached-tree symbols must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y = 1\n".to_string());
assert_eq!(
document_symbols_via_db(&stale.snapshot(), path, buffer, encoding),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
document_symbols_via_db(&empty.snapshot(), path, buffer, encoding),
expected,
"untracked path must fall back to the buffer text"
);
}
}