pmacs/src/symbol.rs

312 lines
10 KiB
Rust

// 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<String>,
/// 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<Symbol>,
}
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<Symbol>) {
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<Symbol>) {
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<String>, uri: impl Into<String>) -> Self {
Self {
server: server.into(),
scope: SymbolScope::Document(uri.into()),
}
}
/// Key for a workspace query.
#[must_use]
pub fn workspace(server: impl Into<String>, query: impl Into<String>) -> Self {
Self {
server: server.into(),
scope: SymbolScope::Workspace(query.into()),
}
}
}
/// Per-`(server, scope)` symbol state.
#[derive(Default)]
pub struct SymbolStore {
by_key: HashMap<SymbolKey, SymbolResponse>,
}
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<Mutex<SymbolStore>>;
/// 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"
);
}
}