diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 170f4d5..042c035 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -259,6 +259,9 @@ pmacs.lsp.request_references = wrap_request(pmacs.lsp._request_references_raw) pmacs.lsp.request_declaration = wrap_request(pmacs.lsp._request_declaration_raw) pmacs.lsp.request_type_definition = wrap_request(pmacs.lsp._request_type_definition_raw) pmacs.lsp.request_implementation = wrap_request(pmacs.lsp._request_implementation_raw) +pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_symbol_raw) +pmacs.lsp.request_workspace_symbol = wrap_request(pmacs.lsp._request_workspace_symbol_raw) +pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_highlight_raw) -- Render an `:await()` failure into a modeline-friendly reason. -- `Handle:await()` raises `{ tag = "cancelled", ... }` when the @@ -417,6 +420,37 @@ function pmacs.lsp.find_references() end) end +function pmacs.lsp.document_symbols() + local rec = attached_for_active() + if not rec then + pmacs.editor.set_status("LSP: no server for active buffer") + return + end + pmacs.document_symbol.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_document_symbol(rec.server, rec.uri):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local syms = pmacs.document_symbol.symbols(rec.server, rec.uri) + if not syms or #syms == 0 then + pmacs.editor.set_status("LSP: no symbols") + return + end + -- v1 modeline summary (count + first symbol); a structured + -- outline buffer driven off this store is future UX work, like + -- the references list and hover panel. + local first = syms[1] + pmacs.editor.set_status(string.format( + "LSP: %d symbol%s; first '%s' at %d:%d", + #syms, (#syms == 1 and "" or "s"), + first.name, first.line + 1, first.col + 1)) + end) +end + function pmacs.lsp.format_buffer() local rec = attached_for_active() if not rec then @@ -531,12 +565,19 @@ pmacs.command.define { fn = pmacs.lsp.find_references, } +pmacs.command.define { + name = "lsp.document-symbols", + description = "List the symbols (outline) of the active buffer (LSP).", + fn = pmacs.lsp.document_symbols, +} + -- Default chords. M-. follows the cross-editor convention for -- go-to-definition; the others sit on `C-c` to keep printable letters -- self-inserting. The user can override or unbind any of these from -- init.lua. pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-definition" } pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" } +pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" } pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" } pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" } pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" } diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 79c35a4..7f1cdad 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -387,6 +387,54 @@ fn main() { }); write_frame(&mut stdout, &resp); } + // T M4.5 symbols/highlight. documentSymbol returns the + // *hierarchical* DocumentSymbol shape (exercises tree + // flatten + depth + parent); workspace/symbol the flat + // SymbolInformation shape (exercises location.uri); + // documentHighlight a two-occurrence list. + ("textDocument/documentSymbol", Some(idv)) => { + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": [{ + "name": "Outer", "kind": 5, + "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 9, "character": 0 } }, + "selectionRange": { "start": { "line": 1, "character": 6 }, "end": { "line": 1, "character": 11 } }, + "children": [{ + "name": "inner", "kind": 6, + "range": { "start": { "line": 3, "character": 2 }, "end": { "line": 5, "character": 2 } }, + "selectionRange": { "start": { "line": 3, "character": 7 }, "end": { "line": 3, "character": 12 } } + }] + }] + }); + write_frame(&mut stdout, &resp); + } + ("workspace/symbol", Some(idv)) => { + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": [{ + "name": "WsThing", "kind": 12, + "location": { + "uri": "file:///ws.rs", + "range": { "start": { "line": 7, "character": 3 }, "end": { "line": 7, "character": 10 } } + }, + "containerName": "modw" + }] + }); + write_frame(&mut stdout, &resp); + } + ("textDocument/documentHighlight", Some(idv)) => { + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": [ + { "range": { "start": { "line": 2, "character": 4 }, "end": { "line": 2, "character": 9 } }, "kind": 2 }, + { "range": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 5 } } } + ] + }); + write_frame(&mut stdout, &resp); + } (_, Some(idv)) => { // Generic echo response. let resp = serde_json::json!({ diff --git a/src/document_highlight.rs b/src/document_highlight.rs new file mode 100644 index 0000000..29fd1bf --- /dev/null +++ b/src/document_highlight.rs @@ -0,0 +1,190 @@ +// document_highlight.rs --- T M4.5: textDocument/documentHighlight. + +//! Per-`(server, uri)` store for `textDocument/documentHighlight`: +//! the ranges in the current document that refer to the same symbol +//! as the cursor (the basis for "highlight all occurrences"). Same +//! request/store/Lua shape as the other M4.5 features; the inbound +//! position codec rewrites the ranges to byte offsets before this +//! parses, so consumers stay byte-uniform. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +/// One highlighted occurrence. `kind` is the raw LSP +/// `DocumentHighlightKind` (1 = Text, 2 = Read, 3 = Write); absent +/// defaults to 1 (Text) per spec. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Highlight { + /// Zero-based start line. + pub start_line: u32, + /// Zero-based start column. + pub start_col: u32, + /// Zero-based end line. + pub end_line: u32, + /// Zero-based end column. + pub end_col: u32, + /// `DocumentHighlightKind` (1 Text / 2 Read / 3 Write). + pub kind: i64, +} + +/// Parsed response: zero or more occurrences in source order. +#[derive(Clone, Debug, Default)] +pub struct DocumentHighlightResponse { + /// Occurrences the server returned. + pub highlights: Vec, +} + +impl DocumentHighlightResponse { + /// Parse `DocumentHighlight[] | null`. + #[must_use] + pub fn from_lsp_value(v: &Value) -> Self { + let mut out = Vec::new(); + if let Some(arr) = v.as_array() { + for item in arr { + let Some(range) = item.get("range") else { + continue; + }; + let (Some(start), Some(end)) = (range.get("start"), range.get("end")) else { + continue; + }; + let g = |p: &Value, k: &str| p.get(k).and_then(Value::as_u64).map(|n| n as u32); + let (Some(sl), Some(sc), Some(el), Some(ec)) = ( + g(start, "line"), + g(start, "character"), + g(end, "line"), + g(end, "character"), + ) else { + continue; + }; + out.push(Highlight { + start_line: sl, + start_col: sc, + end_line: el, + end_col: ec, + kind: item.get("kind").and_then(Value::as_i64).unwrap_or(1), + }); + } + } + Self { highlights: out } + } + + /// True iff the server returned nothing. + #[must_use] + pub fn is_empty(&self) -> bool { + self.highlights.is_empty() + } +} + +/// Key into [`DocumentHighlightStore`]. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct DocumentHighlightKey { + /// Decimal LSP server id. + pub server: String, + /// Document URI. + pub uri: String, +} + +impl DocumentHighlightKey { + /// Construct a key. + #[must_use] + pub fn new(server: impl Into, uri: impl Into) -> Self { + Self { + server: server.into(), + uri: uri.into(), + } + } +} + +/// Per-`(server, uri)` highlight state. +#[derive(Default)] +pub struct DocumentHighlightStore { + by_key: HashMap, +} + +impl DocumentHighlightStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace the response at `key`. + pub fn set(&mut self, key: DocumentHighlightKey, response: DocumentHighlightResponse) { + self.by_key.insert(key, response); + } + + /// Drop the entry at `key`. + pub fn clear(&mut self, key: &DocumentHighlightKey) { + self.by_key.remove(key); + } + + /// Look up the entry at `key`. + #[must_use] + pub fn get(&self, key: &DocumentHighlightKey) -> Option<&DocumentHighlightResponse> { + self.by_key.get(key) + } +} + +/// Cheaply-cloneable shared handle. +pub type SharedDocumentHighlightStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedDocumentHighlightStore { + Arc::new(Mutex::new(DocumentHighlightStore::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_highlights_and_defaults_kind_to_text() { + let v = json!([ + { + "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 5 } }, + "kind": 3 + }, + { + "range": { "start": { "line": 4, "character": 0 }, "end": { "line": 4, "character": 3 } } + } + ]); + let r = DocumentHighlightResponse::from_lsp_value(&v); + assert_eq!(r.highlights.len(), 2); + assert_eq!(r.highlights[0].kind, 3); // Write + assert_eq!( + ( + r.highlights[0].start_line, + r.highlights[0].start_col, + r.highlights[0].end_col + ), + (1, 2, 5) + ); + assert_eq!(r.highlights[1].kind, 1); // absent ⇒ Text + } + + #[test] + fn null_is_empty_and_store_round_trips() { + assert!(DocumentHighlightResponse::from_lsp_value(&Value::Null).is_empty()); + let mut s = DocumentHighlightStore::new(); + let key = DocumentHighlightKey::new("1", "file:///a"); + s.set( + key.clone(), + DocumentHighlightResponse { + highlights: vec![Highlight { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 1, + kind: 2, + }], + }, + ); + assert_eq!(s.get(&key).unwrap().highlights.len(), 1); + s.clear(&key); + assert!(s.get(&key).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index ba4a1ab..2575477 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,6 +51,7 @@ pub mod daemon; pub mod daemon_attach; pub mod definition; pub mod diag; +pub mod document_highlight; pub mod editor; pub mod editor_core; pub mod file_io; @@ -100,6 +101,7 @@ pub mod semantic_client; pub mod semantic_render; pub mod signature; pub mod socket_path; +pub mod symbol; pub mod syntax; pub mod text_view; pub mod transport; diff --git a/src/lsp.rs b/src/lsp.rs index 543c33c..9d51b5f 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -749,6 +749,10 @@ pub struct LspManager { /// implementation. Same Location shape as `definition`, keyed /// additionally by kind so the four don't collide. locations_store: crate::locations::SharedLocationsStore, + /// T M4.5: documentSymbol / workspace symbol, scope-keyed. + symbol_store: crate::symbol::SharedSymbolStore, + /// T M4.5: textDocument/documentHighlight, per `(server, uri)`. + document_highlight_store: crate::document_highlight::SharedDocumentHighlightStore, /// T M4.12 formatting store. Populated when a /// `textDocument/formatting` response lands. formatting_store: crate::formatting::SharedFormattingStore, @@ -818,6 +822,26 @@ enum ResponseRoute { /// Which nav request this answers. kind: crate::locations::LocationKind, }, + /// Absorb a `textDocument/documentSymbol` response into + /// [`crate::symbol::SymbolStore`] at `(server, document uri)`. + DocumentSymbol { + /// The requested document URI (also the codec doc key, and + /// the fallback URI for `DocumentSymbol` items which carry + /// none). + uri: String, + }, + /// Absorb a `workspace/symbol` response into + /// [`crate::symbol::SymbolStore`] at `(server, query)`. + WorkspaceSymbol { + /// The query string this answers. + query: String, + }, + /// Absorb a `textDocument/documentHighlight` response into + /// [`crate::document_highlight::DocumentHighlightStore`]. + DocumentHighlight { + /// Document URI. + uri: String, + }, } impl ResponseRoute { @@ -830,7 +854,14 @@ impl ResponseRoute { | ResponseRoute::Signature { uri } | ResponseRoute::Definition { uri } | ResponseRoute::Formatting { uri } - | ResponseRoute::Locations { uri, .. } => uri, + | ResponseRoute::Locations { uri, .. } + | ResponseRoute::DocumentSymbol { uri } + | ResponseRoute::DocumentHighlight { uri } => uri, + // workspace/symbol results span arbitrary files we have + // not cached — no doc to convert against, so the inbound + // codec must pass coordinates through untouched (same + // non-destructive rule as cross-file definition). + ResponseRoute::WorkspaceSymbol { .. } => "", } } } @@ -901,6 +932,8 @@ impl LspManager { signature_store: crate::signature::make_shared_store(), definition_store: crate::definition::make_shared_store(), locations_store: crate::locations::make_shared_store(), + symbol_store: crate::symbol::make_shared_store(), + document_highlight_store: crate::document_highlight::make_shared_store(), formatting_store: crate::formatting::make_shared_store(), pending_routes: HashMap::new(), status_tracker: crate::lsp_status::LspStatusTracker::new(), @@ -948,6 +981,20 @@ impl LspManager { self.locations_store.clone() } + /// Shared symbol store — documentSymbol / workspace symbol (T M4.5). + #[must_use] + pub fn symbol_store(&self) -> crate::symbol::SharedSymbolStore { + self.symbol_store.clone() + } + + /// Shared documentHighlight store (T M4.5). + #[must_use] + pub fn document_highlight_store( + &self, + ) -> crate::document_highlight::SharedDocumentHighlightStore { + self.document_highlight_store.clone() + } + /// Shared formatting store (T M4.12). #[must_use] pub fn formatting_store(&self) -> crate::formatting::SharedFormattingStore { @@ -1590,6 +1637,63 @@ impl LspManager { ) } + /// Send `textDocument/documentSymbol` (no position — whole doc). + /// Returns the async-runtime [`JobId`]. + pub fn request_document_symbol( + &mut self, + sid: LspServerId, + uri: impl Into, + ) -> Result { + let uri = uri.into(); + let params = json!({ "textDocument": { "uri": uri.clone() } }); + let method = "textDocument/documentSymbol"; + let req_id = self.send_request(sid, method, params)?; + let job_id = self.register_awaiter(sid, req_id, method, &uri); + self.pending_routes + .insert((sid, req_id), ResponseRoute::DocumentSymbol { uri }); + Ok(job_id) + } + + /// Send `workspace/symbol` for `query`. Returns the [`JobId`]. + pub fn request_workspace_symbol( + &mut self, + sid: LspServerId, + query: impl Into, + ) -> Result { + let query = query.into(); + let params = json!({ "query": query.clone() }); + let method = "workspace/symbol"; + let req_id = self.send_request(sid, method, params)?; + // The query stands in for the doc URI in the supersede key: + // a newer query for the same string supersedes the prior. + let job_id = self.register_awaiter(sid, req_id, method, &query); + self.pending_routes + .insert((sid, req_id), ResponseRoute::WorkspaceSymbol { query }); + Ok(job_id) + } + + /// Send `textDocument/documentHighlight` at `(line, col)`. + /// Returns the [`JobId`]. + pub fn request_document_highlight( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + ) -> Result { + let uri = uri.into(); + let params = json!({ + "textDocument": { "uri": uri.clone() }, + "position": self.outbound_position(sid, &uri, line, col) + }); + let method = "textDocument/documentHighlight"; + let req_id = self.send_request(sid, method, params)?; + let job_id = self.register_awaiter(sid, req_id, method, &uri); + self.pending_routes + .insert((sid, req_id), ResponseRoute::DocumentHighlight { uri }); + Ok(job_id) + } + /// Send `textDocument/formatting` for `uri` with `tab_size` / /// `insert_spaces` formatting options. The response is absorbed /// into the formatting store at `(sid, uri)`. Returns the @@ -2153,6 +2257,37 @@ impl LspManager { .expect("locations store mutex poisoned"); guard.set(key, resp); } + ResponseRoute::DocumentSymbol { uri } => { + // DocumentSymbol items carry no URI — they belong to + // the requested document; pass it as the fallback. + let resp = crate::symbol::SymbolResponse::from_lsp_value(result, uri); + let key = crate::symbol::SymbolKey::document(server_key, uri.clone()); + let mut guard = self + .symbol_store + .lock() + .expect("symbol store mutex poisoned"); + guard.set(key, resp); + } + ResponseRoute::WorkspaceSymbol { query } => { + let resp = crate::symbol::SymbolResponse::from_lsp_value(result, ""); + let key = crate::symbol::SymbolKey::workspace(server_key, query.clone()); + let mut guard = self + .symbol_store + .lock() + .expect("symbol store mutex poisoned"); + guard.set(key, resp); + } + ResponseRoute::DocumentHighlight { uri } => { + let resp = + crate::document_highlight::DocumentHighlightResponse::from_lsp_value(result); + let key = + crate::document_highlight::DocumentHighlightKey::new(server_key, uri.clone()); + let mut guard = self + .document_highlight_store + .lock() + .expect("document highlight store mutex poisoned"); + guard.set(key, resp); + } ResponseRoute::Formatting { uri } => { let resp = crate::formatting::FormattingResponse::from_lsp_value(result); let key = crate::formatting::FormattingKey::new(server_key, uri.clone()); diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 5bca3e0..dae2c92 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7264,6 +7264,50 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + { + let m = manager.clone(); + lsp_mod.set( + "_request_document_symbol_raw", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let job_id = m + .borrow_mut() + .request_document_symbol(id.0, uri) + .map_err(mlua::Error::external)?; + Ok(job_id) + })?, + )?; + } + + { + let m = manager.clone(); + lsp_mod.set( + "_request_workspace_symbol_raw", + lua.create_function(move |_, (id, query): (LspServerIdLua, String)| { + let job_id = m + .borrow_mut() + .request_workspace_symbol(id.0, query) + .map_err(mlua::Error::external)?; + Ok(job_id) + })?, + )?; + } + + { + let m = manager.clone(); + lsp_mod.set( + "_request_document_highlight_raw", + lua.create_function( + move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| { + let job_id = m + .borrow_mut() + .request_document_highlight(id.0, uri, line, col) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -7518,6 +7562,8 @@ pub fn make_lsp_manager( install_signature(lua, &manager)?; install_definition(lua, &manager)?; install_locations(lua, &manager)?; + install_symbol(lua, &manager)?; + install_document_highlight(lua, &manager)?; install_formatting(lua, &manager)?; Ok(manager) } @@ -8315,10 +8361,12 @@ pub fn install_diag(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { use crate::completion::{CompletionItem, CompletionItemKind, CompletionKey, CompletionTriggers}; use crate::definition::{DefinitionKey, DefinitionLocation, DefinitionResponse}; +use crate::document_highlight::{DocumentHighlightKey, Highlight}; use crate::formatting::{FormattingKey, FormattingResponse, TextEdit}; use crate::hover::{Hover, HoverKey}; use crate::locations::{LocationKind, LocationsKey}; use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter}; +use crate::symbol::{Symbol as LspSymbol, SymbolKey}; fn completion_item_to_lua(lua: &Lua, item: &CompletionItem) -> mlua::Result { let t = lua.create_table_with_capacity(0, 7)?; @@ -8784,6 +8832,147 @@ pub fn install_locations(lua: &Lua, manager: &SharedLspManager) -> mlua::Result< Ok(()) } +fn symbol_to_lua(lua: &Lua, s: &LspSymbol) -> mlua::Result
{ + let t = lua.create_table_with_capacity(0, 7)?; + t.set("name", s.name.as_str())?; + t.set("kind", s.kind)?; + t.set("uri", s.uri.as_str())?; + t.set("line", s.line)?; + t.set("col", s.col)?; + t.set("depth", s.depth)?; + if let Some(c) = &s.container { + t.set("container", c.as_str())?; + } + Ok(t) +} + +fn highlight_to_lua(lua: &Lua, h: &Highlight) -> mlua::Result
{ + let t = lua.create_table_with_capacity(0, 5)?; + t.set("start_line", h.start_line)?; + t.set("start_col", h.start_col)?; + t.set("end_line", h.end_line)?; + t.set("end_col", h.end_col)?; + t.set("kind", h.kind)?; + Ok(t) +} + +/// Install `pmacs.document_symbol` (`symbols(sid,uri)` / `clear`) and +/// `pmacs.workspace_symbol` (`symbols(sid,query)` / `clear`) over the +/// scope-keyed symbol store. T M4.5. +pub fn install_symbol(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + + let doc = lua.create_table()?; + { + let mgr = manager.clone(); + doc.set( + "symbols", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store = mgr.borrow().symbol_store(); + let guard = store.lock().expect("symbol store mutex poisoned"); + let key = SymbolKey::document(id.0.raw().to_string(), uri); + let out = lua.create_table()?; + if let Some(r) = guard.get(&key) { + for (i, s) in r.symbols.iter().enumerate() { + out.set(i + 1, symbol_to_lua(lua, s)?)?; + } + } + Ok(Value::Table(out)) + })?, + )?; + } + { + let mgr = manager.clone(); + doc.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store = mgr.borrow().symbol_store(); + let mut guard = store.lock().expect("symbol store mutex poisoned"); + guard.clear(&SymbolKey::document(id.0.raw().to_string(), uri)); + Ok(()) + })?, + )?; + } + pmacs.set("document_symbol", doc)?; + + let ws = lua.create_table()?; + { + let mgr = manager.clone(); + ws.set( + "symbols", + lua.create_function(move |lua, (id, query): (LspServerIdLua, String)| { + let store = mgr.borrow().symbol_store(); + let guard = store.lock().expect("symbol store mutex poisoned"); + let key = SymbolKey::workspace(id.0.raw().to_string(), query); + let out = lua.create_table()?; + if let Some(r) = guard.get(&key) { + for (i, s) in r.symbols.iter().enumerate() { + out.set(i + 1, symbol_to_lua(lua, s)?)?; + } + } + Ok(Value::Table(out)) + })?, + )?; + } + { + let mgr = manager.clone(); + ws.set( + "clear", + lua.create_function(move |_, (id, query): (LspServerIdLua, String)| { + let store = mgr.borrow().symbol_store(); + let mut guard = store.lock().expect("symbol store mutex poisoned"); + guard.clear(&SymbolKey::workspace(id.0.raw().to_string(), query)); + Ok(()) + })?, + )?; + } + pmacs.set("workspace_symbol", ws)?; + Ok(()) +} + +/// Install `pmacs.document_highlight` (`highlights(sid,uri)` / +/// `clear`). T M4.5. +pub fn install_document_highlight(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let m = lua.create_table()?; + { + let mgr = manager.clone(); + m.set( + "highlights", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store = mgr.borrow().document_highlight_store(); + let guard = store + .lock() + .expect("document highlight store mutex poisoned"); + let key = DocumentHighlightKey::new(id.0.raw().to_string(), uri); + let out = lua.create_table()?; + if let Some(r) = guard.get(&key) { + for (i, h) in r.highlights.iter().enumerate() { + out.set(i + 1, highlight_to_lua(lua, h)?)?; + } + } + Ok(Value::Table(out)) + })?, + )?; + } + { + let mgr = manager.clone(); + m.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store = mgr.borrow().document_highlight_store(); + let mut guard = store + .lock() + .expect("document highlight store mutex poisoned"); + guard.clear(&DocumentHighlightKey::new(id.0.raw().to_string(), uri)); + Ok(()) + })?, + )?; + } + pmacs.set("document_highlight", m)?; + Ok(()) +} + fn text_edit_to_lua(lua: &Lua, edit: &TextEdit) -> mlua::Result
{ let t = lua.create_table_with_capacity(0, 5)?; t.set("start_line", edit.start_line)?; diff --git a/src/protocol.rs b/src/protocol.rs index 97fbb1e..516a4d2 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -3943,13 +3943,19 @@ mod tests { buffer_id: bid, items: vec![BlockAdornment { at: 64, - replaces: Some(ByteRange { start: 64, end: 256 }), + replaces: Some(ByteRange { + start: 64, + end: 256, + }), content: AdornmentContent::Resource { handle: 1 }, }], }, InstanceMessage::FoldState { buffer_id: bid, - folds: vec![ByteRange { start: 100, end: 400 }], + folds: vec![ByteRange { + start: 100, + end: 400, + }], }, InstanceMessage::ResourceOffer { handle: 1, diff --git a/src/semantic_client.rs b/src/semantic_client.rs index dbb6cce..c2f9acc 100644 --- a/src/semantic_client.rs +++ b/src/semantic_client.rs @@ -332,10 +332,12 @@ impl SemanticClient { /// declared viewport, or no styling there). #[must_use] pub fn effective_style_at(&self, buffer_id: BufferId, byte: u64) -> Style { - self.styles.get(&buffer_id).map_or_else(Style::default, |m| { - m.items_at(byte) - .fold(Style::default(), |acc, s| merge_styles(acc, s.style)) - }) + self.styles + .get(&buffer_id) + .map_or_else(Style::default, |m| { + m.items_at(byte) + .fold(Style::default(), |acc, s| merge_styles(acc, s.style)) + }) } /// The decoration kinds covering `byte`, in instance order @@ -343,9 +345,9 @@ impl SemanticClient { /// selection and a diagnostic). #[must_use] pub fn decoration_kinds_at(&self, buffer_id: BufferId, byte: u64) -> Vec { - self.decos.get(&buffer_id).map_or_else(Vec::new, |m| { - m.items_at(byte).map(|d| d.kind).collect() - }) + self.decos + .get(&buffer_id) + .map_or_else(Vec::new, |m| m.items_at(byte).map(|d| d.kind).collect()) } /// Reconstructed styling tile ranges for `buffer_id` — for diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 7b72d76..2e26235 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -246,11 +246,7 @@ impl SemanticRenderState { /// frontend already owns (it has `CursorByte`) — emitting it would /// couple a visual-motion concern to the instance, against the /// contract boundary. - fn scoped_decorations( - &self, - state: &EditorState, - vp: &DeclaredViewport, - ) -> Vec { + fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec { let core = state.core.borrow(); let mut out = Vec::new(); @@ -585,7 +581,10 @@ mod tests { msgs.iter().find_map(|m| match m { InstanceMessage::Decorations { full, segments, .. } => Some(( *full, - segments.iter().flat_map(|s| s.decorations.clone()).collect(), + segments + .iter() + .flat_map(|s| s.decorations.clone()) + .collect(), )), _ => None, }) @@ -655,7 +654,14 @@ mod tests { let state = empty_state(); let mut s = local(); let buffer_id = active_buffer(&state); - s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0); + s.set_viewport( + buffer_id, + ByteRange { + start: 0, + end: 4096, + }, + 0, + ); // Empty scratch: no spans, no selection, no diagnostics — but // the first frame is a `full` resync for both families (the @@ -745,11 +751,21 @@ mod tests { // Declaring a different on-screen range forces a full resync: // prior styling/decorations are positioned for the old window. - s.set_viewport(buffer_id, ByteRange { start: 200, end: 264 }, 0); + s.set_viewport( + buffer_id, + ByteRange { + start: 200, + end: 264, + }, + 0, + ); let msgs = s.render_frame(&state); let (style_full, _) = style_segments(&msgs).expect("StyleSpans"); let (deco_full, _) = decorations_of(&msgs).expect("Decorations"); - assert!(style_full && deco_full, "viewport jump must be a full resync"); + assert!( + style_full && deco_full, + "viewport jump must be a full resync" + ); } #[test] diff --git a/src/symbol.rs b/src/symbol.rs new file mode 100644 index 0000000..1367e39 --- /dev/null +++ b/src/symbol.rs @@ -0,0 +1,311 @@ +// 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" + ); + } +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index c9d0dc4..28e1cad 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -4015,3 +4015,67 @@ fn m4_5_location_nav_requests_route_by_kind() { assert_eq!(tdef, 31, "typeDefinition must route to its own slot"); assert_eq!(impl_, 41, "implementation must route to its own slot"); } + +/// T M4.5 symbols/highlight: documentSymbol (hierarchical → flattened +/// with depth + parent), workspace/symbol (flat, location.uri), and +/// documentHighlight (range + kind, default 1) each await end-to-end +/// and land in their store with the right shape. +#[test] +fn m4_5_symbols_and_highlight_round_trip() { + use pmacs::editor::EditorState; + let mut state = EditorState::new(); + spawn_lsp_and_init(&mut state, None); + state + .lua_host + .lua() + .load( + "local uri='file:///s.rs' + pmacs.lsp.did_open(_G._lsp, uri, 1, 'mod m {}\\n') + _G._done=false + pmacs.async(function() + pmacs.lsp.request_document_symbol(_G._lsp, uri):await() + pmacs.lsp.request_workspace_symbol(_G._lsp, 'q'):await() + pmacs.lsp.request_document_highlight(_G._lsp, uri, 0, 4):await() + local ds = pmacs.document_symbol.symbols(_G._lsp, uri) + local ws = pmacs.workspace_symbol.symbols(_G._lsp, 'q') + local dh = pmacs.document_highlight.highlights(_G._lsp, uri) + _G._ds_n = ds and #ds or 0 + _G._ds1 = ds and ds[1] and ds[1].name or '' + _G._ds2 = ds and ds[2] and ds[2].name or '' + _G._ds2d = ds and ds[2] and ds[2].depth or -1 + _G._ds2c = ds and ds[2] and ds[2].container or '' + _G._ws_uri = ws and ws[1] and ws[1].uri or '' + _G._ws_ctr = ws and ws[1] and ws[1].container or '' + _G._dh_n = dh and #dh or 0 + _G._dh1k = dh and dh[1] and dh[1].kind or -1 + _G._dh2k = dh and dh[2] and dh[2].kind or -1 + _G._done=true + end)", + ) + .exec() + .expect("dispatch symbols/highlight coroutine"); + assert!( + pump_lua_flag(&mut state, "_G._done", 5), + "symbols/highlight coroutine never completed" + ); + let lua = state.lua_host.lua(); + let g = |k: &str| -> String { + lua.load(format!("return tostring(_G.{k})")) + .eval() + .unwrap_or_default() + }; + assert_eq!(g("_ds_n"), "2", "documentSymbol flattens parent+child"); + assert_eq!(g("_ds1"), "Outer"); + assert_eq!(g("_ds2"), "inner"); + assert_eq!(g("_ds2d"), "1", "child depth is 1"); + assert_eq!(g("_ds2c"), "Outer", "child container is the parent"); + assert_eq!( + g("_ws_uri"), + "file:///ws.rs", + "workspace symbol location.uri" + ); + assert_eq!(g("_ws_ctr"), "modw"); + assert_eq!(g("_dh_n"), "2"); + assert_eq!(g("_dh1k"), "2", "explicit DocumentHighlightKind (Read)"); + assert_eq!(g("_dh2k"), "1", "absent kind defaults to Text(1)"); +}