// symbol.rs --- T M4.5: documentSymbol / workspace symbol. //! Shared store for `textDocument/documentSymbol` and //! `workspace/symbol`. Both ultimately describe "a named program //! entity at a location", so one flat [`Symbol`] type serves both; //! the only differences are (a) the request response can arrive in //! two LSP shapes — hierarchical `DocumentSymbol[]` or flat //! `SymbolInformation[]` / `WorkspaceSymbol[]` — handled by //! [`SymbolResponse::from_lsp_value`], and (b) the store is keyed by //! a [`SymbolScope`] so a per-document outline and a workspace query //! don't collide. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use serde_json::Value; /// One symbol, flattened. `kind` is the raw LSP `SymbolKind` integer /// (1..=26); consumers map it to a label. Coordinates are LSP-native /// (the inbound position codec rewrites them to byte offsets before /// this parses, for the document-symbol case where they belong to /// the requested doc; workspace-symbol locations are cross-file and /// pass through unconverted). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Symbol { /// Symbol name. pub name: String, /// Raw LSP `SymbolKind` (1..=26). pub kind: i64, /// Containing document URI. pub uri: String, /// Zero-based line of the symbol's name. pub line: u32, /// Zero-based column of the symbol's name. pub col: u32, /// `containerName` (flat shapes) or the parent chain joined with /// `::` (hierarchical), if any. pub container: Option, /// Nesting depth in a hierarchical `DocumentSymbol` tree; 0 for /// flat shapes. pub depth: u32, } /// Parsed symbol response: a flat, source-ordered list. #[derive(Clone, Debug, Default)] pub struct SymbolResponse { /// Symbols in document/source order, parents before children. pub symbols: Vec, } fn range_start(v: &Value, key: &str) -> Option<(u32, u32)> { let start = v.get(key)?.get("start")?; Some(( start.get("line")?.as_u64()? as u32, start.get("character")?.as_u64()? as u32, )) } impl SymbolResponse { /// Parse `DocumentSymbol[] | SymbolInformation[] | /// WorkspaceSymbol[] | null`. `default_uri` is the requested /// document — used for `DocumentSymbol`, which carries no URI /// (its positions are relative to the requested document). #[must_use] pub fn from_lsp_value(v: &Value, default_uri: &str) -> Self { let mut out = Vec::new(); if let Some(arr) = v.as_array() { for item in arr { if item.get("location").is_some() { // SymbolInformation / WorkspaceSymbol (flat). Self::push_flat(item, &mut out); } else { // DocumentSymbol (hierarchical). Self::push_hier(item, default_uri, None, 0, &mut out); } } } Self { symbols: out } } fn push_flat(item: &Value, out: &mut Vec) { let Some(name) = item.get("name").and_then(Value::as_str) else { return; }; let kind = item.get("kind").and_then(Value::as_i64).unwrap_or(0); let loc = item.get("location"); let uri = loc .and_then(|l| l.get("uri")) .and_then(Value::as_str) .unwrap_or("") .to_owned(); // WorkspaceSymbol may carry `location: { uri }` with no range. let (line, col) = loc.and_then(|l| range_start(l, "range")).unwrap_or((0, 0)); let container = item .get("containerName") .and_then(Value::as_str) .filter(|s| !s.is_empty()) .map(ToOwned::to_owned); out.push(Symbol { name: name.to_owned(), kind, uri, line, col, container, depth: 0, }); } fn push_hier(item: &Value, uri: &str, parent: Option<&str>, depth: u32, out: &mut Vec) { let Some(name) = item.get("name").and_then(Value::as_str) else { return; }; let kind = item.get("kind").and_then(Value::as_i64).unwrap_or(0); // `selectionRange` is the name; `range` the whole body. let (line, col) = range_start(item, "selectionRange") .or_else(|| range_start(item, "range")) .unwrap_or((0, 0)); out.push(Symbol { name: name.to_owned(), kind, uri: uri.to_owned(), line, col, container: parent.map(ToOwned::to_owned), depth, }); if let Some(children) = item.get("children").and_then(Value::as_array) { let child_parent = match parent { Some(p) => format!("{p}::{name}"), None => name.to_owned(), }; for c in children { Self::push_hier(c, uri, Some(&child_parent), depth + 1, out); } } } /// True iff the server returned no symbols. #[must_use] pub fn is_empty(&self) -> bool { self.symbols.is_empty() } } /// What a stored [`SymbolResponse`] answers. #[derive(Clone, Eq, PartialEq, Hash, Debug)] pub enum SymbolScope { /// `textDocument/documentSymbol` for a document URI. Document(String), /// `workspace/symbol` for a query string. Workspace(String), } /// Key into [`SymbolStore`]. #[derive(Clone, Eq, PartialEq, Hash, Debug)] pub struct SymbolKey { /// Decimal LSP server id. pub server: String, /// Document URI or workspace query. pub scope: SymbolScope, } impl SymbolKey { /// Key for a document outline. #[must_use] pub fn document(server: impl Into, uri: impl Into) -> Self { Self { server: server.into(), scope: SymbolScope::Document(uri.into()), } } /// Key for a workspace query. #[must_use] pub fn workspace(server: impl Into, query: impl Into) -> Self { Self { server: server.into(), scope: SymbolScope::Workspace(query.into()), } } } /// Per-`(server, scope)` symbol state. #[derive(Default)] pub struct SymbolStore { by_key: HashMap, } impl SymbolStore { /// Empty store. #[must_use] pub fn new() -> Self { Self::default() } /// Replace the response at `key`. pub fn set(&mut self, key: SymbolKey, response: SymbolResponse) { self.by_key.insert(key, response); } /// Drop the entry at `key`. pub fn clear(&mut self, key: &SymbolKey) { self.by_key.remove(key); } /// Look up the entry at `key`. #[must_use] pub fn get(&self, key: &SymbolKey) -> Option<&SymbolResponse> { self.by_key.get(key) } } /// Cheaply-cloneable shared handle. pub type SharedSymbolStore = Arc>; /// Build a fresh shared store. #[must_use] pub fn make_shared_store() -> SharedSymbolStore { Arc::new(Mutex::new(SymbolStore::new())) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn parses_hierarchical_document_symbols_with_depth_and_parent() { let v = json!([{ "name": "Outer", "kind": 5, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 9, "character": 0 } }, "selectionRange": { "start": { "line": 0, "character": 6 }, "end": { "line": 0, "character": 11 } }, "children": [{ "name": "method", "kind": 6, "range": { "start": { "line": 2, "character": 2 }, "end": { "line": 4, "character": 2 } }, "selectionRange": { "start": { "line": 2, "character": 7 }, "end": { "line": 2, "character": 13 } } }] }]); let r = SymbolResponse::from_lsp_value(&v, "file:///m.rs"); assert_eq!(r.symbols.len(), 2); assert_eq!(r.symbols[0].name, "Outer"); assert_eq!(r.symbols[0].depth, 0); assert_eq!((r.symbols[0].line, r.symbols[0].col), (0, 6)); // selectionRange assert_eq!(r.symbols[0].uri, "file:///m.rs"); // DocumentSymbol carries none assert_eq!(r.symbols[1].name, "method"); assert_eq!(r.symbols[1].depth, 1); assert_eq!(r.symbols[1].container.as_deref(), Some("Outer")); } #[test] fn parses_flat_symbol_information() { let v = json!([{ "name": "Thing", "kind": 23, "location": { "uri": "file:///a.rs", "range": { "start": { "line": 4, "character": 8 }, "end": { "line": 4, "character": 13 } } }, "containerName": "modx" }]); let r = SymbolResponse::from_lsp_value(&v, "file:///ignored"); assert_eq!(r.symbols.len(), 1); assert_eq!(r.symbols[0].uri, "file:///a.rs"); // from location, not default assert_eq!((r.symbols[0].line, r.symbols[0].col), (4, 8)); assert_eq!(r.symbols[0].container.as_deref(), Some("modx")); assert_eq!(r.symbols[0].depth, 0); } #[test] fn workspace_symbol_without_range_defaults_to_origin() { // WorkspaceSymbol permits `location: { uri }` with no range. let v = json!([{ "name": "Z", "kind": 12, "location": { "uri": "file:///z.go" } }]); let r = SymbolResponse::from_lsp_value(&v, ""); assert_eq!(r.symbols.len(), 1); assert_eq!(r.symbols[0].uri, "file:///z.go"); assert_eq!((r.symbols[0].line, r.symbols[0].col), (0, 0)); } #[test] fn null_is_empty_and_scopes_do_not_collide() { assert!(SymbolResponse::from_lsp_value(&Value::Null, "x").is_empty()); let mut s = SymbolStore::new(); let one = |n: &str| SymbolResponse { symbols: vec![Symbol { name: n.into(), kind: 1, uri: "u".into(), line: 0, col: 0, container: None, depth: 0, }], }; s.set(SymbolKey::document("1", "file:///a"), one("doc")); s.set(SymbolKey::workspace("1", "file:///a"), one("ws")); assert_eq!( s.get(&SymbolKey::document("1", "file:///a")) .unwrap() .symbols[0] .name, "doc" ); assert_eq!( s.get(&SymbolKey::workspace("1", "file:///a")) .unwrap() .symbols[0] .name, "ws" ); } }