feat: document symbols for the outline

Add textDocument/documentSymbol: walk the lossless tree into a nested
DocumentSymbol tree. Object members become symbols named by their key
with a kind from the value type and a scalar-value preview as detail;
array elements become indexed children. The selection range is the key,
the full range spans the member. No schema or network involved.
This commit is contained in:
2026-07-01 09:02:06 +02:00
parent d835f841f1
commit 69564c252e
6 changed files with 235 additions and 1 deletions
+1
View File
@@ -3,3 +3,4 @@ pub mod diagnostics;
pub mod formatting;
pub mod hover;
pub mod schema_action;
pub mod symbols;
+153
View File
@@ -0,0 +1,153 @@
use crate::document::Document;
use crate::lsp::DocumentSymbol;
use crate::lsp::capabilities::PositionEncoding;
use crate::lsp::symbol_kind;
use crate::syntax::token::SyntaxKind;
use crate::syntax::tree::Node;
/// Build the document's outline: object members and array elements as a
/// nested symbol tree.
pub fn document_symbols(
doc: &Document,
encoding: PositionEncoding,
) -> Vec<DocumentSymbol> {
let Some(root) = doc.parse.value() else {
return Vec::new();
};
Builder { doc, encoding }.children_of(root)
}
struct Builder<'a> {
doc: &'a Document,
encoding: PositionEncoding,
}
impl Builder<'_> {
/// The symbols contained directly in a value: object members or array
/// elements. Scalars contain nothing.
fn children_of(&self, node: &Node) -> Vec<DocumentSymbol> {
match node.kind {
SyntaxKind::Object => {
node.members().filter_map(|m| self.member(m)).collect()
}
SyntaxKind::Array => node
.elements()
.enumerate()
.map(|(i, el)| self.element(i, el))
.collect(),
_ => Vec::new(),
}
}
fn member(&self, member: &Node) -> Option<DocumentSymbol> {
let key = member.key_token()?;
let name = member.key_text()?;
let value = member.value_node();
Some(DocumentSymbol {
name,
detail: value.and_then(detail),
kind: value.map_or(symbol_kind::KEY, |v| kind(v.kind)),
range: self.range(member),
selection_range: self.doc.line_index.range(
&self.doc.text,
key.range,
self.encoding,
),
children: value
.map(|v| self.children_of(v))
.filter(|c| !c.is_empty()),
})
}
fn element(&self, index: usize, value: &Node) -> DocumentSymbol {
let range = self.range(value);
DocumentSymbol {
name: index.to_string(),
detail: detail(value),
kind: kind(value.kind),
range,
selection_range: range,
children: Some(self.children_of(value)).filter(|c| !c.is_empty()),
}
}
fn range(&self, node: &Node) -> crate::lsp::Range {
self.doc
.line_index
.range(&self.doc.text, node.range, self.encoding)
}
}
fn kind(node_kind: SyntaxKind) -> u8 {
match node_kind {
SyntaxKind::Object => symbol_kind::OBJECT,
SyntaxKind::Array => symbol_kind::ARRAY,
SyntaxKind::StringValue => symbol_kind::STRING,
SyntaxKind::NumberValue => symbol_kind::NUMBER,
SyntaxKind::BoolValue => symbol_kind::BOOLEAN,
SyntaxKind::NullValue => symbol_kind::NULL,
_ => symbol_kind::VARIABLE,
}
}
/// A short value preview for scalars, shown next to the symbol name.
fn detail(node: &Node) -> Option<String> {
match node.kind {
SyntaxKind::StringValue
| SyntaxKind::NumberValue
| SyntaxKind::BoolValue
| SyntaxKind::NullValue => node.scalar_token().map(|t| t.text.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::document_symbols;
use crate::document::Document;
use crate::lsp::capabilities::PositionEncoding::Utf16;
use crate::lsp::symbol_kind;
fn symbols(text: &str) -> Vec<crate::lsp::DocumentSymbol> {
let doc = Document::new("file:///t.json".to_owned(), 1, text.into());
document_symbols(&doc, Utf16)
}
#[test]
fn top_level_object_members_become_symbols() {
let syms = symbols(r#"{"name": "x", "count": 2}"#);
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["name", "count"]);
assert_eq!(syms[0].kind, symbol_kind::STRING);
assert_eq!(syms[1].kind, symbol_kind::NUMBER);
assert_eq!(syms[1].detail.as_deref(), Some("2"));
}
#[test]
fn nested_objects_and_arrays_nest() {
let syms = symbols(r#"{"a": {"b": 1}, "c": [true, null]}"#);
let a = &syms[0];
assert_eq!(a.kind, symbol_kind::OBJECT);
assert_eq!(a.children.as_ref().unwrap()[0].name, "b");
let c = &syms[1];
assert_eq!(c.kind, symbol_kind::ARRAY);
let items = c.children.as_ref().unwrap();
assert_eq!(items[0].name, "0");
assert_eq!(items[0].kind, symbol_kind::BOOLEAN);
assert_eq!(items[1].name, "1");
assert_eq!(items[1].kind, symbol_kind::NULL);
}
#[test]
fn scalar_root_has_no_symbols() {
assert!(symbols("42").is_empty());
}
#[test]
fn selection_range_covers_the_key() {
let syms = symbols(r#"{"name": "x"}"#);
// The key "name" starts at character 1.
assert_eq!(syms[0].selection_range.start.character, 1);
}
}
+4 -1
View File
@@ -137,7 +137,10 @@ fn schema_is_fetched_only_on_the_download_command() {
));
let lenses = response_result(&lens_out).as_array().unwrap();
assert_eq!(lenses.len(), 1);
assert_eq!(lenses[0]["command"]["command"], json!("json-ls.downloadSchema"));
assert_eq!(
lenses[0]["command"]["command"],
json!("json-ls.downloadSchema")
);
// Running the command triggers a real (network-allowed) fetch.
server.handle(request(
+5
View File
@@ -143,6 +143,11 @@ pub struct ServerCapabilities {
pub document_formatting_provider: Option<bool>,
#[serde(rename = "hoverProvider", skip_serializing_if = "Option::is_none")]
pub hover_provider: Option<bool>,
#[serde(
rename = "documentSymbolProvider",
skip_serializing_if = "Option::is_none"
)]
pub document_symbol_provider: Option<bool>,
#[serde(
rename = "codeLensProvider",
skip_serializing_if = "Option::is_none"
+52
View File
@@ -263,6 +263,58 @@ pub struct ExecuteCommandParams {
pub arguments: Vec<serde_json::Value>,
}
/// Parameters for `textDocument/documentSymbol`.
#[derive(Debug, Clone, Deserialize)]
pub struct DocumentSymbolParams {
#[serde(rename = "textDocument")]
pub text_document: TextDocumentIdentifier,
}
/// The complete `SymbolKind` set.
pub mod symbol_kind {
#![expect(dead_code, reason = "complete SymbolKind set kept for reference")]
pub const FILE: u8 = 1;
pub const MODULE: u8 = 2;
pub const NAMESPACE: u8 = 3;
pub const PACKAGE: u8 = 4;
pub const CLASS: u8 = 5;
pub const METHOD: u8 = 6;
pub const PROPERTY: u8 = 7;
pub const FIELD: u8 = 8;
pub const CONSTRUCTOR: u8 = 9;
pub const ENUM: u8 = 10;
pub const INTERFACE: u8 = 11;
pub const FUNCTION: u8 = 12;
pub const VARIABLE: u8 = 13;
pub const CONSTANT: u8 = 14;
pub const STRING: u8 = 15;
pub const NUMBER: u8 = 16;
pub const BOOLEAN: u8 = 17;
pub const ARRAY: u8 = 18;
pub const OBJECT: u8 = 19;
pub const KEY: u8 = 20;
pub const NULL: u8 = 21;
pub const ENUM_MEMBER: u8 = 22;
pub const STRUCT: u8 = 23;
pub const EVENT: u8 = 24;
pub const OPERATOR: u8 = 25;
pub const TYPE_PARAMETER: u8 = 26;
}
/// A node in the document's symbol tree.
#[derive(Debug, Clone, Serialize)]
pub struct DocumentSymbol {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub kind: u8,
pub range: Range,
#[serde(rename = "selectionRange")]
pub selection_range: Range,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<DocumentSymbol>>,
}
/// A single diagnostic (syntax error, schema violation, or lint).
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
+20
View File
@@ -9,6 +9,7 @@ use crate::features::diagnostics;
use crate::features::formatting;
use crate::features::hover;
use crate::features::schema_action;
use crate::features::symbols;
use crate::lsp::CodeActionParams;
use crate::lsp::CodeLensParams;
use crate::lsp::CompletionParams;
@@ -17,6 +18,7 @@ use crate::lsp::DidChangeTextDocumentParams;
use crate::lsp::DidCloseTextDocumentParams;
use crate::lsp::DidOpenTextDocumentParams;
use crate::lsp::DocumentFormattingParams;
use crate::lsp::DocumentSymbolParams;
use crate::lsp::ExecuteCommandParams;
use crate::lsp::PublishDiagnosticsParams;
use crate::lsp::TextDocumentPositionParams;
@@ -113,6 +115,9 @@ impl Server {
"textDocument/formatting" => self.on_formatting(id, params),
"textDocument/completion" => self.on_completion(id, params),
"textDocument/hover" => self.on_hover(id, params),
"textDocument/documentSymbol" => {
self.on_document_symbol(id, params)
}
"textDocument/codeLens" => self.on_code_lens(id, params),
"textDocument/codeAction" => self.on_code_action(id, params),
"workspace/executeCommand" => self.on_execute_command(id, params),
@@ -289,6 +294,20 @@ impl Server {
vec![Outgoing::Response(Response::ok(id, value))]
}
fn on_document_symbol(
&self,
id: Value,
params: Option<Value>,
) -> Vec<Outgoing> {
let items = parse_params::<DocumentSymbolParams>(params)
.ok()
.and_then(|p| self.documents.get(&p.text_document.uri))
.map(|doc| symbols::document_symbols(doc, self.encoding))
.unwrap_or_default();
let value = serde_json::to_value(items).unwrap_or(Value::Null);
vec![Outgoing::Response(Response::ok(id, value))]
}
fn on_code_lens(&self, id: Value, params: Option<Value>) -> Vec<Outgoing> {
let lenses = parse_params::<CodeLensParams>(params)
.ok()
@@ -399,6 +418,7 @@ impl Server {
}),
document_formatting_provider: Some(true),
hover_provider: Some(true),
document_symbol_provider: Some(true),
code_lens_provider: Some(CodeLensOptions {
resolve_provider: false,
}),