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:
@@ -3,3 +3,4 @@ pub mod diagnostics;
|
||||
pub mod formatting;
|
||||
pub mod hover;
|
||||
pub mod schema_action;
|
||||
pub mod symbols;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user