diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index ce59fe4..170f4d5 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -255,6 +255,10 @@ pmacs.lsp.request_hover = wrap_request(pmacs.lsp._request_hover_raw) pmacs.lsp.request_signature_help = wrap_request(pmacs.lsp._request_signature_help_raw) pmacs.lsp.request_definition = wrap_request(pmacs.lsp._request_definition_raw) pmacs.lsp.request_formatting = wrap_request(pmacs.lsp._request_formatting_raw) +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) -- Render an `:await()` failure into a modeline-friendly reason. -- `Handle:await()` raises `{ tag = "cancelled", ... }` when the @@ -381,6 +385,38 @@ function pmacs.lsp.go_to_definition() end) end +function pmacs.lsp.find_references() + local rec = attached_for_active() + if not rec then + pmacs.editor.set_status("LSP: no server for active buffer") + return + end + local line = pmacs.editor.cursor_line() + local col = pmacs.editor.cursor_col() + pmacs.references.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_references(rec.server, rec.uri, line, col):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local locs = pmacs.references.locations(rec.server, rec.uri) + if not locs or #locs == 0 then + pmacs.editor.set_status("LSP: no references found") + return + end + -- v1 surfaces a modeline summary (count + first hit); a + -- references list buffer is future UX work, like the hover panel. + local first = locs[1] + pmacs.editor.set_status(string.format( + "LSP: %d reference%s; first at %s:%d:%d", + #locs, (#locs == 1 and "" or "s"), + first.uri, first.line + 1, first.col + 1)) + end) +end + function pmacs.lsp.format_buffer() local rec = attached_for_active() if not rec then @@ -489,11 +525,18 @@ pmacs.command.define { fn = pmacs.lsp.signature_help_at_cursor, } +pmacs.command.define { + name = "lsp.find-references", + description = "Find references to the symbol under the cursor (LSP).", + fn = pmacs.lsp.find_references, +} + -- 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 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 c9fc496..79c35a4 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -329,6 +329,39 @@ fn main() { }); write_frame(&mut stdout, &resp); } + // T M4.5 Location-shaped nav. Distinct line per method so + // a test can confirm each routes into its own kind slot. + ( + m @ ("textDocument/references" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation"), + Some(idv), + ) => { + let uri = params + .get("textDocument") + .and_then(|t| t.get("uri")) + .cloned() + .unwrap_or(serde_json::Value::Null); + let line = match m { + "textDocument/references" => 11, + "textDocument/declaration" => 21, + "textDocument/typeDefinition" => 31, + _ => 41, // implementation + }; + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": [{ + "uri": uri, + "range": { + "start": { "line": line, "character": 2 }, + "end": { "line": line, "character": 6 } + } + }] + }); + write_frame(&mut stdout, &resp); + } ("textDocument/formatting", Some(idv)) => { // Synthetic two-edit reply: trim leading whitespace on // line 0 and append a semicolon at line 3, col 7. diff --git a/src/lib.rs b/src/lib.rs index 9668714..d122d64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,7 @@ pub mod instance_render; pub mod key; pub mod keymap_stack; pub mod keymap_tree; +pub mod locations; pub mod lockfile; pub mod lsp; pub mod lsp_status; diff --git a/src/locations.rs b/src/locations.rs new file mode 100644 index 0000000..d616587 --- /dev/null +++ b/src/locations.rs @@ -0,0 +1,166 @@ +// locations.rs --- T M4.5: Location-shaped navigation requests. + +//! Shared store for the navigation requests whose response shape is +//! identical to `textDocument/definition` +//! (`Location | Location[] | LocationLink[] | null`): `references`, +//! `declaration`, `typeDefinition`, `implementation`. Parsing is +//! reused verbatim from [`crate::definition::DefinitionResponse`]; +//! this module only adds a `kind` discriminator so the four request +//! types don't collide on `(server, uri)` the way they would in the +//! existing single-purpose definition store. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::definition::DefinitionResponse; + +/// Which Location-shaped request a stored response answers. +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +pub enum LocationKind { + /// `textDocument/references`. + References, + /// `textDocument/declaration`. + Declaration, + /// `textDocument/typeDefinition`. + TypeDefinition, + /// `textDocument/implementation`. + Implementation, +} + +impl LocationKind { + /// Stable lowercase label — the `pmacs.lsp.*` Lua surface name, + /// and the supersede-/store-key discriminator. + #[must_use] + pub fn label(self) -> &'static str { + match self { + LocationKind::References => "references", + LocationKind::Declaration => "declaration", + LocationKind::TypeDefinition => "type_definition", + LocationKind::Implementation => "implementation", + } + } + + /// The LSP request method name. + #[must_use] + pub fn method(self) -> &'static str { + match self { + LocationKind::References => "textDocument/references", + LocationKind::Declaration => "textDocument/declaration", + LocationKind::TypeDefinition => "textDocument/typeDefinition", + LocationKind::Implementation => "textDocument/implementation", + } + } +} + +/// Key into [`LocationsStore`]. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct LocationsKey { + /// Decimal LSP server id. + pub server: String, + /// Document URI. + pub uri: String, + /// Which request this entry answers. + pub kind: LocationKind, +} + +impl LocationsKey { + /// Construct a key. + #[must_use] + pub fn new(server: impl Into, uri: impl Into, kind: LocationKind) -> Self { + Self { + server: server.into(), + uri: uri.into(), + kind, + } + } +} + +/// Per-`(server, uri, kind)` Location-list state. The value type is +/// [`DefinitionResponse`] — same shape, same parser. +#[derive(Default)] +pub struct LocationsStore { + by_key: HashMap, +} + +impl LocationsStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace the response at `key`. + pub fn set(&mut self, key: LocationsKey, response: DefinitionResponse) { + self.by_key.insert(key, response); + } + + /// Drop the entry at `key`. + pub fn clear(&mut self, key: &LocationsKey) { + self.by_key.remove(key); + } + + /// Look up the entry at `key`. + #[must_use] + pub fn get(&self, key: &LocationsKey) -> Option<&DefinitionResponse> { + self.by_key.get(key) + } +} + +/// Cheaply-cloneable shared handle. +pub type SharedLocationsStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedLocationsStore { + Arc::new(Mutex::new(LocationsStore::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::definition::DefinitionLocation; + + #[test] + fn kind_labels_and_methods_are_distinct() { + let kinds = [ + LocationKind::References, + LocationKind::Declaration, + LocationKind::TypeDefinition, + LocationKind::Implementation, + ]; + let mut labels: Vec<&str> = kinds.iter().map(|k| k.label()).collect(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), 4, "labels must be unique"); + for k in kinds { + assert!(k.method().starts_with("textDocument/")); + } + } + + #[test] + fn kind_keys_do_not_collide() { + let mut s = LocationsStore::new(); + let mk = |kind| LocationsKey::new("1", "file:///a", kind); + let one = |line| DefinitionResponse { + locations: vec![DefinitionLocation { + uri: "file:///t".into(), + line, + col: 0, + }], + }; + s.set(mk(LocationKind::References), one(1)); + s.set(mk(LocationKind::Implementation), one(2)); + // Same (server, uri) but different kind ⇒ independent entries. + assert_eq!( + s.get(&mk(LocationKind::References)).unwrap().locations[0].line, + 1 + ); + assert_eq!( + s.get(&mk(LocationKind::Implementation)).unwrap().locations[0].line, + 2 + ); + s.clear(&mk(LocationKind::References)); + assert!(s.get(&mk(LocationKind::References)).is_none()); + assert!(s.get(&mk(LocationKind::Implementation)).is_some()); + } +} diff --git a/src/lsp.rs b/src/lsp.rs index fd078a4..5c0c9d0 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -745,6 +745,10 @@ pub struct LspManager { /// T M4.12 definition store. Populated when a /// `textDocument/definition` response lands. definition_store: crate::definition::SharedDefinitionStore, + /// T M4.5: references / declaration / typeDefinition / + /// implementation. Same Location shape as `definition`, keyed + /// additionally by kind so the four don't collide. + locations_store: crate::locations::SharedLocationsStore, /// T M4.12 formatting store. Populated when a /// `textDocument/formatting` response lands. formatting_store: crate::formatting::SharedFormattingStore, @@ -805,6 +809,15 @@ enum ResponseRoute { /// Absorb response into [`crate::formatting::FormattingStore`] at /// `(server, uri)`. Formatting { uri: String }, + /// Absorb a Location-shaped nav response (references / declaration + /// / typeDefinition / implementation) into + /// [`crate::locations::LocationsStore`] at `(server, uri, kind)`. + Locations { + /// Document URI. + uri: String, + /// Which nav request this answers. + kind: crate::locations::LocationKind, + }, } impl ResponseRoute { @@ -816,7 +829,8 @@ impl ResponseRoute { | ResponseRoute::Hover { uri } | ResponseRoute::Signature { uri } | ResponseRoute::Definition { uri } - | ResponseRoute::Formatting { uri } => uri, + | ResponseRoute::Formatting { uri } + | ResponseRoute::Locations { uri, .. } => uri, } } } @@ -886,6 +900,7 @@ impl LspManager { hover_store: crate::hover::make_shared_store(), signature_store: crate::signature::make_shared_store(), definition_store: crate::definition::make_shared_store(), + locations_store: crate::locations::make_shared_store(), formatting_store: crate::formatting::make_shared_store(), pending_routes: HashMap::new(), status_tracker: crate::lsp_status::LspStatusTracker::new(), @@ -926,6 +941,13 @@ impl LspManager { self.definition_store.clone() } + /// Shared locations store — references / declaration / + /// typeDefinition / implementation (T M4.5). + #[must_use] + pub fn locations_store(&self) -> crate::locations::SharedLocationsStore { + self.locations_store.clone() + } + /// Shared formatting store (T M4.12). #[must_use] pub fn formatting_store(&self) -> crate::formatting::SharedFormattingStore { @@ -1461,6 +1483,113 @@ impl LspManager { Ok(job_id) } + /// Shared body for the Location-shaped nav requests. `extra` is + /// merged into the params object (only `references` uses it, for + /// `context.includeDeclaration`). The supersede key derives from + /// the kind's distinct method, so the four requests don't cancel + /// one another. Returns the async-runtime [`JobId`]. + fn request_locations( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + kind: crate::locations::LocationKind, + extra: Option, + ) -> Result { + let uri = uri.into(); + let mut params = json!({ + "textDocument": { "uri": uri.clone() }, + "position": self.outbound_position(sid, &uri, line, col) + }); + if let Some(Value::Object(ex)) = extra + && let Some(obj) = params.as_object_mut() + { + for (k, v) in ex { + obj.insert(k, v); + } + } + let method = kind.method(); + 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::Locations { uri, kind }); + Ok(job_id) + } + + /// Send `textDocument/references` (with `includeDeclaration`). + /// Returns the async-runtime [`JobId`]. + pub fn request_references( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + ) -> Result { + self.request_locations( + sid, + uri, + line, + col, + crate::locations::LocationKind::References, + Some(json!({ "context": { "includeDeclaration": true } })), + ) + } + + /// Send `textDocument/declaration`. Returns the [`JobId`]. + pub fn request_declaration( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + ) -> Result { + self.request_locations( + sid, + uri, + line, + col, + crate::locations::LocationKind::Declaration, + None, + ) + } + + /// Send `textDocument/typeDefinition`. Returns the [`JobId`]. + pub fn request_type_definition( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + ) -> Result { + self.request_locations( + sid, + uri, + line, + col, + crate::locations::LocationKind::TypeDefinition, + None, + ) + } + + /// Send `textDocument/implementation`. Returns the [`JobId`]. + pub fn request_implementation( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + ) -> Result { + self.request_locations( + sid, + uri, + line, + col, + crate::locations::LocationKind::Implementation, + None, + ) + } + /// 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 @@ -2013,6 +2142,17 @@ impl LspManager { .expect("definition store mutex poisoned"); guard.set(key, resp); } + ResponseRoute::Locations { uri, kind } => { + // Same Location parser as definition; only the store + // key carries the kind discriminator. + let resp = crate::definition::DefinitionResponse::from_lsp_value(result); + let key = crate::locations::LocationsKey::new(server_key, uri.clone(), *kind); + let mut guard = self + .locations_store + .lock() + .expect("locations 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 0100564..5bca3e0 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7200,6 +7200,70 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + { + let m = manager.clone(); + lsp_mod.set( + "_request_references_raw", + lua.create_function( + move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| { + let job_id = m + .borrow_mut() + .request_references(id.0, uri, line, col) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + + { + let m = manager.clone(); + lsp_mod.set( + "_request_declaration_raw", + lua.create_function( + move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| { + let job_id = m + .borrow_mut() + .request_declaration(id.0, uri, line, col) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + + { + let m = manager.clone(); + lsp_mod.set( + "_request_type_definition_raw", + lua.create_function( + move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| { + let job_id = m + .borrow_mut() + .request_type_definition(id.0, uri, line, col) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + + { + let m = manager.clone(); + lsp_mod.set( + "_request_implementation_raw", + lua.create_function( + move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| { + let job_id = m + .borrow_mut() + .request_implementation(id.0, uri, line, col) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -7453,6 +7517,7 @@ pub fn make_lsp_manager( install_hover(lua, &manager)?; install_signature(lua, &manager)?; install_definition(lua, &manager)?; + install_locations(lua, &manager)?; install_formatting(lua, &manager)?; Ok(manager) } @@ -8252,6 +8317,7 @@ use crate::completion::{CompletionItem, CompletionItemKind, CompletionKey, Compl use crate::definition::{DefinitionKey, DefinitionLocation, DefinitionResponse}; use crate::formatting::{FormattingKey, FormattingResponse, TextEdit}; use crate::hover::{Hover, HoverKey}; +use crate::locations::{LocationKind, LocationsKey}; use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter}; fn completion_item_to_lua(lua: &Lua, item: &CompletionItem) -> mlua::Result { @@ -8672,6 +8738,52 @@ pub fn install_definition(lua: &Lua, manager: &SharedLspManager) -> mlua::Result Ok(()) } +/// Install `pmacs.references` / `.declaration` / `.type_definition` +/// / `.implementation`, each `{ locations(sid,uri), clear(sid,uri) }`, +/// mirroring `pmacs.definition` (same Location-list shape, hence the +/// reused `definition_response_to_lua`). T M4.5. +pub fn install_locations(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + for kind in [ + LocationKind::References, + LocationKind::Declaration, + LocationKind::TypeDefinition, + LocationKind::Implementation, + ] { + let m = lua.create_table()?; + { + let mgr = manager.clone(); + m.set( + "locations", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().locations_store(); + let guard = store_handle.lock().expect("locations store mutex poisoned"); + let key = LocationsKey::new(id.0.raw().to_string(), uri, kind); + if let Some(r) = guard.get(&key) { + Ok(Value::Table(definition_response_to_lua(lua, r)?)) + } else { + Ok(Value::Table(lua.create_table_with_capacity(0, 0)?)) + } + })?, + )?; + } + { + let mgr = manager.clone(); + m.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().locations_store(); + let mut guard = store_handle.lock().expect("locations store mutex poisoned"); + guard.clear(&LocationsKey::new(id.0.raw().to_string(), uri, kind)); + Ok(()) + })?, + )?; + } + pmacs.set(kind.label(), 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/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 85d24e8..c9d0dc4 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3963,3 +3963,55 @@ fn m4_5_workspace_configuration_answered_from_settings() { from the spec settings; got {got:?}" ); } + +/// T M4.5 nav batch: references / declaration / typeDefinition / +/// implementation each await end-to-end through the async bridge and +/// land in their *own* kind-keyed slot (no collision on +/// `(server, uri)` — the reason for the dedicated locations store). +/// The fake returns a distinct line per method (11/21/31/41), so the +/// per-kind Lua surfaces must read back exactly those. +#[test] +fn m4_5_location_nav_requests_route_by_kind() { + use pmacs::editor::EditorState; + let mut state = EditorState::new(); + spawn_lsp_and_init(&mut state, None); + state + .lua_host + .lua() + .load( + "local uri='file:///n.rs' + pmacs.lsp.did_open(_G._lsp, uri, 1, 'fn x() {}\\n') + _G._done=false + pmacs.async(function() + pmacs.lsp.request_references(_G._lsp, uri, 0, 3):await() + pmacs.lsp.request_declaration(_G._lsp, uri, 0, 3):await() + pmacs.lsp.request_type_definition(_G._lsp, uri, 0, 3):await() + pmacs.lsp.request_implementation(_G._lsp, uri, 0, 3):await() + local function l(t) + local x = t.locations(_G._lsp, uri) + return (x and x[1] and x[1].line) or -1 + end + _G._refs = l(pmacs.references) + _G._decl = l(pmacs.declaration) + _G._tdef = l(pmacs.type_definition) + _G._impl = l(pmacs.implementation) + _G._done = true + end)", + ) + .exec() + .expect("dispatch nav coroutine"); + assert!( + pump_lua_flag(&mut state, "_G._done", 5), + "nav coroutine never completed" + ); + let (refs, decl, tdef, impl_): (i64, i64, i64, i64) = state + .lua_host + .lua() + .load("return _G._refs, _G._decl, _G._tdef, _G._impl") + .eval() + .expect("read kind lines"); + assert_eq!(refs, 11, "references must route to its own slot"); + assert_eq!(decl, 21, "declaration must route to its own slot"); + assert_eq!(tdef, 31, "typeDefinition must route to its own slot"); + assert_eq!(impl_, 41, "implementation must route to its own slot"); +}