T M4.5: nav requests — references/declaration/typeDefinition/implementation

The "cheap batch" subset that is genuine template-fill: these four
return the exact `Location | Location[] | LocationLink[] | null`
shape `textDocument/definition` already parses, so no new parsing —
only a kind discriminator so they don't collide on (server, uri).

- src/locations.rs: a (server, uri, kind)-keyed store whose value
  type is the reused crate::definition::DefinitionResponse. The
  proven definition store + Lua API are untouched.
- lsp.rs: ResponseRoute::Locations { uri, kind }; one absorb arm; a
  DRY request_locations helper + request_references /
  request_declaration / request_type_definition /
  request_implementation. references sends
  context.includeDeclaration. Supersede keys derive from each
  kind's distinct method, so the four don't cancel each other.
- Lua: _request_*_raw bindings + install_locations exposing
  pmacs.references / .declaration / .type_definition /
  .implementation ({ locations, clear }, mirroring pmacs.definition,
  reusing definition_response_to_lua). lsp.lua Handle wrappers + a
  lsp.find-references command bound to M-? (modeline summary;
  references-list buffer is future UX, like the hover panel).
- Tests: locations.rs unit tests (kind labels distinct; keys don't
  collide); e2e driving all four through the async bridge and
  asserting each routes to its own kind slot (fake returns distinct
  lines 11/21/31/41).

Scoped: documentSymbol / workspaceSymbol / documentHighlight return
different shapes (new parsing) — a separate follow-up, not crammed
in here.

Gate: fmt clean; clippy --all-targets -D warnings clean; lib
1232/0; m4_acceptance 65/0; m9_1 18/0; m8_1/m8_9/m8_10 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-18 21:39:02 -04:00
parent deb9935ee0
commit 96780b547d
7 changed files with 548 additions and 1 deletions

View File

@ -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" }

View File

@ -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.

View File

@ -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;

166
src/locations.rs Normal file
View File

@ -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<String>, uri: impl Into<String>, 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<LocationsKey, DefinitionResponse>,
}
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<Mutex<LocationsStore>>;
/// 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());
}
}

View File

@ -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<String>,
line: u32,
col: u32,
kind: crate::locations::LocationKind,
extra: Option<Value>,
) -> Result<JobId, String> {
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<String>,
line: u32,
col: u32,
) -> Result<JobId, String> {
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<String>,
line: u32,
col: u32,
) -> Result<JobId, String> {
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<String>,
line: u32,
col: u32,
) -> Result<JobId, String> {
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<String>,
line: u32,
col: u32,
) -> Result<JobId, String> {
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());

View File

@ -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<Table> {
@ -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<Table> {
let t = lua.create_table_with_capacity(0, 5)?;
t.set("start_line", edit.start_line)?;

View File

@ -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");
}