From 878ef7a80a0612495221da79e029288e1e5e577a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 19 May 2026 14:23:57 -0400 Subject: [PATCH] T M4.5: textDocument/prepareRename (backlog item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built on #25 + #26 (branched off main after both merged). - src/prepare_rename.rs: parse the prepareRename union null | Range | { range, placeholder } | { defaultBehavior } into { allowed, placeholder?, range? }. Store/Key/Shared keyed (server, uri). 7 unit tests. - src/lsp.rs: prepare_rename_store + accessor, ResponseRoute::PrepareRename + absorb, request_prepare_rename; capability rename.prepareSupport=true + prepareSupportDefaultBehavior=1. - src/lua_bindings.rs: _request_prepare_rename_raw, pmacs.prepare_rename.{result,clear}. - builtin/runtime/lsp.lua: pmacs.lsp.rename() gates on caps.renameProvider.prepareProvider. When supported: prepareRename round-trip first — null/not-allowed -> "cannot rename here", the prompt never opens; otherwise the prompt opens pre-filled with the server's placeholder (minibuffer `initial`). Servers without prepare keep the unchanged L2 path (on_accept factored into a shared local; no behavior change there). - pmacs_fake_lsp.rs: `prepare`/`preprefuse` modes inject renameProvider.prepareProvider + a prepareRename arm ({range,placeholder} or null). Default `rename` mode untouched so m4_13 still exercises the no-prepare path. - tests/m4_acceptance.rs: m4_22 (prompt waits for the async prepare, placeholder pre-filled, rename applies), m4_23 (refusal: prompt never opens, buffer untouched). Gates: lib 1299/0, m4 78/0, m8_1 10/0, m8_9 26/0, m8_10 19/0, m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy clean. Co-Authored-By: Claude Opus 4.7 --- builtin/runtime/lsp.lua | 133 ++++++++++++++-------- src/bin/pmacs_fake_lsp.rs | 29 +++++ src/lib.rs | 1 + src/lsp.rs | 68 +++++++++++- src/lua_bindings.rs | 76 +++++++++++++ src/prepare_rename.rs | 228 ++++++++++++++++++++++++++++++++++++++ tests/m4_acceptance.rs | 189 +++++++++++++++++++++++++++++++ 7 files changed, 671 insertions(+), 53 deletions(-) create mode 100644 src/prepare_rename.rs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 85c2eae..6c3e9cc 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -12,7 +12,8 @@ -- Scope: one server per language across all buffers; async-await -- request/react (the editor never blocks). Landed: cross-file -- go-to-definition (L1), multi-file rename / WorkspaceEdit applier --- (L2), code actions + `workspace/executeCommand` + server→client +-- (L2) with `textDocument/prepareRename` gating when the server +-- supports it, code actions + `workspace/executeCommand` + server→client -- `workspace/applyEdit` (L3), ordered resource-op edits -- (create/rename/delete file) with buffer-registry reconciliation -- (L4), inlay hints, and semantic tokens (each data + modeline; @@ -272,6 +273,7 @@ pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_sym 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) pmacs.lsp.request_rename = wrap_request(pmacs.lsp._request_rename_raw) +pmacs.lsp.request_prepare_rename = wrap_request(pmacs.lsp._request_prepare_rename_raw) pmacs.lsp.request_code_action = wrap_request(pmacs.lsp._request_code_action_raw) pmacs.lsp.request_execute_command = wrap_request(pmacs.lsp._request_execute_command_raw) pmacs.lsp.request_inlay_hint = wrap_request(pmacs.lsp._request_inlay_hint_raw) @@ -784,12 +786,17 @@ function pmacs.lsp.format_buffer() end) end --- T M4.5 L2 — rename the symbol under the cursor. Prompts for the new --- name in the minibuffer; on accept, sends `textDocument/rename`, --- awaits the `WorkspaceEdit`, and drives it through the multi-file --- applier. The position is captured *before* the prompt opens so the --- request still targets the original symbol even though the minibuffer --- session moved focus. +-- T M4.5 — rename the symbol under the cursor. +-- +-- When the server advertises `renameProvider.prepareProvider`, a +-- `textDocument/prepareRename` round-trip runs first: it gates the +-- prompt (a `null` result means "not renameable here" — abort with a +-- status, never open the prompt) and pre-fills the placeholder the +-- server suggests. Servers that don't advertise prepare (or advertise +-- `renameProvider: true`) skip straight to the prompt — the original +-- L2 behavior, unchanged. The cursor position is captured *before* +-- the prompt opens so the request still targets the original symbol +-- even though the minibuffer session moved focus. function pmacs.lsp.rename() local rec = attached_for_active() if not rec then @@ -798,48 +805,78 @@ function pmacs.lsp.rename() end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() - pmacs.minibuffer.read { - prompt = "Rename symbol to: ", - on_cancel = function() - pmacs.editor.set_status("LSP: rename cancelled") - end, - on_accept = function(new_name) - if not new_name or new_name == "" then - pmacs.editor.set_status("LSP: rename needs a new name") - return - end - pmacs.rename.clear(rec.server, rec.uri) - pmacs.async(function() - local ok, err = pcall(function() - pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await() + + local function open_prompt(initial) + pmacs.minibuffer.read { + prompt = "Rename symbol to: ", + initial = initial, + on_cancel = function() + pmacs.editor.set_status("LSP: rename cancelled") + end, + on_accept = function(new_name) + if not new_name or new_name == "" then + pmacs.editor.set_status("LSP: rename needs a new name") + return + end + pmacs.rename.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local ops = pmacs.rename.ops(rec.server, rec.uri) + if not ops or #ops == 0 then + pmacs.editor.set_status("LSP: rename produced no edits") + return + end + local n, files, res = apply_workspace_edit(ops) + if not n then + -- Preflight rejected it; nothing was mutated. + pmacs.editor.set_status("LSP: rename aborted: " .. tostring(files)) + return + end + local msg = string.format( + "LSP: renamed — %d edit%s across %d file%s", + n, (n == 1 and "" or "s"), + files, (files == 1 and "" or "s")) + if res and res > 0 then + msg = msg .. string.format( + " (+%d file op%s)", res, (res == 1 and "" or "s")) + end + pmacs.editor.set_status(msg) end) - if not ok then - pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) - return - end - local ops = pmacs.rename.ops(rec.server, rec.uri) - if not ops or #ops == 0 then - pmacs.editor.set_status("LSP: rename produced no edits") - return - end - local n, files, res = apply_workspace_edit(ops) - if not n then - -- Preflight rejected it; nothing was mutated. - pmacs.editor.set_status("LSP: rename aborted: " .. tostring(files)) - return - end - local msg = string.format( - "LSP: renamed — %d edit%s across %d file%s", - n, (n == 1 and "" or "s"), - files, (files == 1 and "" or "s")) - if res and res > 0 then - msg = msg .. string.format( - " (+%d file op%s)", res, (res == 1 and "" or "s")) - end - pmacs.editor.set_status(msg) - end) - end, - } + end, + } + end + + -- Gate on the server advertising prepareRename. `renameProvider` + -- is `boolean | { prepareProvider?: boolean }`. + local caps = pmacs.lsp.capabilities(rec.server) + local rp = caps and caps.renameProvider + if not (type(rp) == "table" and rp.prepareProvider == true) then + open_prompt(nil) + return + end + + pmacs.prepare_rename.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_prepare_rename(rec.server, rec.uri, line, col):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local pr = pmacs.prepare_rename.result(rec.server, rec.uri) + if not pr or not pr.allowed then + pmacs.editor.set_status("LSP: cannot rename here") + return + end + open_prompt(pr.placeholder) + end) end -- T M4.5 L3 — code actions at the cursor. Requests the actions, diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index dfee2c3..1593d29 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -140,6 +140,13 @@ fn main() { resp["result"]["capabilities"]["positionEncoding"] = serde_json::Value::from("utf-16"); } + // T M4.5: advertise prepareRename only in the + // prepare-* modes, so the default `rename` mode keeps + // exercising the no-prepare path. + if mode == "prepare" || mode == "preprefuse" { + resp["result"]["capabilities"]["renameProvider"] = + serde_json::json!({ "prepareProvider": true }); + } write_frame(&mut stdout, &resp); if mode == "crash" { crashed_after_init = true; @@ -430,6 +437,28 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("textDocument/prepareRename", Some(idv)) => { + // T M4.5: `preprefuse` → null (not renameable here); + // otherwise the `{ range, placeholder }` shape over + // the line-0 cols 3..6 span ("foo"). + let result = if mode == "preprefuse" { + serde_json::Value::Null + } else { + serde_json::json!({ + "range": { + "start": { "line": 0, "character": 3 }, + "end": { "line": 0, "character": 6 } + }, + "placeholder": "foo" + }) + }; + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": result + }); + write_frame(&mut stdout, &resp); + } ("textDocument/rename", Some(idv)) => { // T M4.5 L2: reply with a `WorkspaceEdit`. The edit // replaces the 3-char span at line 0, cols 3..6 with diff --git a/src/lib.rs b/src/lib.rs index d30c2d0..bd6be35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,7 @@ pub mod overlay; pub mod overlay_color; pub mod overlay_paint; pub mod packages; +pub mod prepare_rename; pub mod presence; pub mod process; pub mod project; diff --git a/src/lsp.rs b/src/lsp.rs index 8a8bf1a..d173496 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -770,6 +770,10 @@ pub struct LspManager { /// T M4.5 L2 rename store. Populated when a `textDocument/rename` /// response (a `WorkspaceEdit`) lands, keyed by the origin uri. rename_store: crate::rename::SharedRenameStore, + /// T M4.5 prepareRename store. Populated when a + /// `textDocument/prepareRename` response lands, keyed by `(server, + /// uri)`. + prepare_rename_store: crate::prepare_rename::SharedPrepareRenameStore, /// T M4.5 L3 code-action store. Populated when a /// `textDocument/codeAction` response lands, keyed by `(server, /// uri)`. @@ -842,6 +846,10 @@ enum ResponseRoute { /// Absorb a `textDocument/rename` `WorkspaceEdit` into /// [`crate::rename::RenameStore`] at `(server, origin uri)`. Rename { uri: String }, + /// Absorb a `textDocument/prepareRename` response into + /// [`crate::prepare_rename::PrepareRenameStore`] at `(server, + /// uri)`. + PrepareRename { uri: String }, /// Absorb a `textDocument/codeAction` response into /// [`crate::code_action::CodeActionStore`] at `(server, uri)`. CodeAction { uri: String }, @@ -898,6 +906,7 @@ impl ResponseRoute { | ResponseRoute::Definition { uri } | ResponseRoute::Formatting { uri } | ResponseRoute::Rename { uri } + | ResponseRoute::PrepareRename { uri } | ResponseRoute::CodeAction { uri } | ResponseRoute::InlayHint { uri } | ResponseRoute::SemanticTokens { uri } @@ -984,6 +993,7 @@ impl LspManager { document_highlight_store: crate::document_highlight::make_shared_store(), formatting_store: crate::formatting::make_shared_store(), rename_store: crate::rename::make_shared_store(), + prepare_rename_store: crate::prepare_rename::make_shared_store(), code_action_store: crate::code_action::make_shared_store(), inlay_hint_store: crate::inlay_hint::make_shared_store(), semantic_token_store: crate::semantic_tokens::make_shared_store(), @@ -1059,6 +1069,12 @@ impl LspManager { self.rename_store.clone() } + /// Shared prepareRename store (T M4.5). + #[must_use] + pub fn prepare_rename_store(&self) -> crate::prepare_rename::SharedPrepareRenameStore { + self.prepare_rename_store.clone() + } + /// Shared code-action store (T M4.5 L3). #[must_use] pub fn code_action_store(&self) -> crate::code_action::SharedCodeActionStore { @@ -1861,6 +1877,30 @@ impl LspManager { Ok(job_id) } + /// Send `textDocument/prepareRename` for the symbol at `(line, + /// col)` in `uri`. The response (renameable? + extent + + /// placeholder) is absorbed into the prepareRename store at + /// `(sid, uri)`. Returns the async-runtime [`JobId`] the response + /// will settle. + pub fn request_prepare_rename( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + ) -> Result { + let uri = uri.into(); + let params = json!({ + "textDocument": { "uri": uri.clone() }, + "position": { "line": line, "character": col }, + }); + let req_id = self.send_request(sid, "textDocument/prepareRename", params)?; + let job_id = self.register_awaiter(sid, req_id, "textDocument/prepareRename", &uri); + self.pending_routes + .insert((sid, req_id), ResponseRoute::PrepareRename { uri }); + Ok(job_id) + } + /// Send `textDocument/codeAction` over `[start, end]` in `uri`. /// `context.diagnostics` is passed through verbatim (callers feed /// the overlapping diagnostics so quick-fixes resolve); an empty @@ -2608,6 +2648,15 @@ impl LspManager { .expect("rename store mutex poisoned"); guard.set(key, resp); } + ResponseRoute::PrepareRename { uri } => { + let resp = crate::prepare_rename::PrepareRenameResponse::from_lsp_value(result); + let key = crate::prepare_rename::PrepareRenameKey::new(server_key, uri.clone()); + let mut guard = self + .prepare_rename_store + .lock() + .expect("prepare rename store mutex poisoned"); + guard.set(key, resp); + } ResponseRoute::CodeAction { uri } => { let resp = crate::code_action::CodeActionResponse::from_lsp_value(result); let key = crate::code_action::CodeActionKey::new(server_key, uri.clone()); @@ -3082,11 +3131,20 @@ fn default_capabilities() -> Value { "signatureHelp": { "dynamicRegistration": false }, "definition": { "dynamicRegistration": false, "linkSupport": true }, "formatting": { "dynamicRegistration": false }, - // T M4.5 L2: client-side rename. `prepareSupport: false` - // — pmacs sends `textDocument/rename` directly without a - // `prepareRename` round-trip (L2 scope); the symbol range - // comes from the cursor position. - "rename": { "dynamicRegistration": false, "prepareSupport": false }, + // T M4.5: client-side rename. `prepareSupport: true` — + // the rename flow does a `textDocument/prepareRename` + // round-trip first *when the server advertises + // `renameProvider.prepareProvider`* (it gates the prompt + // and pre-fills the placeholder); otherwise it sends + // `textDocument/rename` straight from the cursor. + // `prepareSupportDefaultBehavior: 1` (Identifier) tells + // the server we can handle the `{ defaultBehavior }` + // shape (compute the word range ourselves). + "rename": { + "dynamicRegistration": false, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + }, // T M4.5 L3: code actions. `codeActionLiteralSupport` // tells servers we accept the richer `CodeAction` shape // (title/kind/edit/command), not just bare `Command`s. diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 3002943..83b9010 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7502,6 +7502,22 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + { + let m = manager.clone(); + lsp_mod.set( + "_request_prepare_rename_raw", + lua.create_function( + move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| { + let job_id = m + .borrow_mut() + .request_prepare_rename(id.0, uri, line, col) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -7881,6 +7897,7 @@ pub fn make_lsp_manager( install_document_highlight(lua, &manager)?; install_formatting(lua, &manager)?; install_rename(lua, &manager)?; + install_prepare_rename(lua, &manager)?; install_code_action(lua, &manager)?; install_inlay_hint(lua, &manager)?; install_semantic_tokens(lua, &manager)?; @@ -8686,6 +8703,7 @@ use crate::formatting::{FormattingKey, FormattingResponse, TextEdit}; use crate::hover::{Hover, HoverKey}; use crate::inlay_hint::{InlayHint as LspInlayHint, InlayHintKey}; use crate::locations::{LocationKind, LocationsKey}; +use crate::prepare_rename::PrepareRenameKey; use crate::rename::{RenameKey, WorkspaceEditResponse, WorkspaceOp}; use crate::semantic_tokens::{ SemanticToken as LspSemanticToken, SemanticTokenKey, SemanticTokensLegend, @@ -9496,6 +9514,64 @@ pub fn install_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> Ok(()) } +/// Install `pmacs.prepare_rename.*` (T M4.5). `result(sid, uri)` +/// returns `{ allowed, placeholder?, start_line?, start_col?, +/// end_line?, end_col? }` for the last `textDocument/prepareRename`, +/// or nil if none landed; `clear(sid, uri)` drops the entry. The +/// rename flow reads `allowed` to gate the prompt and `placeholder` +/// to pre-fill it. +pub fn install_prepare_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let m = lua.create_table()?; + + { + let mgr = manager.clone(); + m.set( + "result", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().prepare_rename_store(); + let guard = store_handle + .lock() + .expect("prepare rename store mutex poisoned"); + let key = PrepareRenameKey::new(id.0.raw().to_string(), uri); + let Some(r) = guard.get(&key) else { + return Ok(Value::Nil); + }; + let t = lua.create_table_with_capacity(0, 6)?; + t.set("allowed", r.allowed)?; + if let Some(p) = r.placeholder.as_deref() { + t.set("placeholder", p)?; + } + if let Some((sl, sc, el, ec)) = r.range { + t.set("start_line", sl)?; + t.set("start_col", sc)?; + t.set("end_line", el)?; + t.set("end_col", ec)?; + } + Ok(Value::Table(t)) + })?, + )?; + } + + { + let mgr = manager.clone(); + m.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().prepare_rename_store(); + let mut guard = store_handle + .lock() + .expect("prepare rename store mutex poisoned"); + guard.clear(&PrepareRenameKey::new(id.0.raw().to_string(), uri)); + Ok(()) + })?, + )?; + } + + pmacs.set("prepare_rename", m)?; + Ok(()) +} + fn code_action_item_to_lua(lua: &Lua, a: &CodeActionItem) -> mlua::Result { let t = lua.create_table_with_capacity(0, 4)?; t.set("title", a.title.as_str())?; diff --git a/src/prepare_rename.rs b/src/prepare_rename.rs new file mode 100644 index 0000000..a059070 --- /dev/null +++ b/src/prepare_rename.rs @@ -0,0 +1,228 @@ +// prepare_rename.rs --- T M4.5 LSP textDocument/prepareRename. + +//! `textDocument/prepareRename` response state. +//! +//! Before prompting for a new name, the client can ask the server +//! whether the symbol at the cursor is renameable and what its extent +//! is. The response is a union: +//! +//! * `null` — rename is **not** valid here. +//! * `Range` — the symbol's range. +//! * `{ range, placeholder }` — range plus a suggested initial +//! value for the rename prompt. +//! * `{ defaultBehavior: bool }` — the server defers the range +//! computation to the client (use the word under the cursor). +//! +//! All shapes collapse into [`PrepareRenameResponse`]: `allowed` (the +//! one bit the rename flow gates on), an optional `placeholder` to +//! pre-fill the prompt, and an optional `range`. Like every other LSP +//! feature this is data only — the rename flow in `lsp.lua` reads the +//! store and decides whether to open the prompt. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +/// Parsed `textDocument/prepareRename` response. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PrepareRenameResponse { + /// False iff the server returned `null` (or `defaultBehavior: + /// false`) — rename must not proceed. + pub allowed: bool, + /// Suggested prompt pre-fill, when the server sent one. + pub placeholder: Option, + /// The symbol range `(start_line, start_col, end_line, end_col)` + /// when the server sent one (absent for the `defaultBehavior` + /// shape — the client uses the cursor word). + pub range: Option<(u32, u32, u32, u32)>, +} + +fn parse_range(v: &Value) -> Option<(u32, u32, u32, u32)> { + let start = v.get("start")?; + let end = v.get("end")?; + Some(( + start.get("line")?.as_u64()? as u32, + start.get("character")?.as_u64()? as u32, + end.get("line")?.as_u64()? as u32, + end.get("character")?.as_u64()? as u32, + )) +} + +impl PrepareRenameResponse { + /// Parse the `Range | { range, placeholder } | { defaultBehavior } + /// | null` union. An unrecognised non-null shape is treated as + /// "not allowed" rather than guessed at. + #[must_use] + pub fn from_lsp_value(v: &Value) -> Self { + if v.is_null() { + return Self::default(); + } + // `{ defaultBehavior: bool }` — allowed iff the bool is true; + // no explicit range (client uses the cursor word). + if let Some(b) = v.get("defaultBehavior").and_then(Value::as_bool) { + return Self { + allowed: b, + placeholder: None, + range: None, + }; + } + // `{ range, placeholder? }` + if let Some(r) = v.get("range") { + return Self { + allowed: true, + placeholder: v + .get("placeholder") + .and_then(Value::as_str) + .map(str::to_owned), + range: parse_range(r), + }; + } + // Bare `Range` (has `start` & `end`). + if let Some(rng) = parse_range(v) { + return Self { + allowed: true, + placeholder: None, + range: Some(rng), + }; + } + Self::default() + } +} + +/// Per-server, per-uri prepareRename state. +#[derive(Default)] +pub struct PrepareRenameStore { + by_key: HashMap, +} + +/// Key into [`PrepareRenameStore`]. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct PrepareRenameKey { + /// Decimal LSP server id. + pub server: String, + /// Document URI the request was made on. + pub uri: String, +} + +impl PrepareRenameKey { + /// Construct a key. + #[must_use] + pub fn new(server: impl Into, uri: impl Into) -> Self { + Self { + server: server.into(), + uri: uri.into(), + } + } +} + +impl PrepareRenameStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace the response at `key`. + pub fn set(&mut self, key: PrepareRenameKey, response: PrepareRenameResponse) { + self.by_key.insert(key, response); + } + + /// Drop the entry at `key`. + pub fn clear(&mut self, key: &PrepareRenameKey) { + self.by_key.remove(key); + } + + /// Look up the entry at `key`. + #[must_use] + pub fn get(&self, key: &PrepareRenameKey) -> Option<&PrepareRenameResponse> { + self.by_key.get(key) + } +} + +/// Cheaply-cloneable shared handle. +pub type SharedPrepareRenameStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedPrepareRenameStore { + Arc::new(Mutex::new(PrepareRenameStore::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn null_is_not_allowed() { + let r = PrepareRenameResponse::from_lsp_value(&Value::Null); + assert!(!r.allowed); + assert!(r.placeholder.is_none()); + assert!(r.range.is_none()); + } + + #[test] + fn bare_range_is_allowed() { + let v = json!({ + "start": { "line": 2, "character": 4 }, + "end": { "line": 2, "character": 9 } + }); + let r = PrepareRenameResponse::from_lsp_value(&v); + assert!(r.allowed); + assert_eq!(r.range, Some((2, 4, 2, 9))); + assert!(r.placeholder.is_none()); + } + + #[test] + fn range_with_placeholder() { + let v = json!({ + "range": { + "start": { "line": 0, "character": 3 }, + "end": { "line": 0, "character": 6 } + }, + "placeholder": "foo" + }); + let r = PrepareRenameResponse::from_lsp_value(&v); + assert!(r.allowed); + assert_eq!(r.placeholder.as_deref(), Some("foo")); + assert_eq!(r.range, Some((0, 3, 0, 6))); + } + + #[test] + fn default_behavior_true_allowed_no_range() { + let r = PrepareRenameResponse::from_lsp_value(&json!({ "defaultBehavior": true })); + assert!(r.allowed); + assert!(r.range.is_none()); + assert!(r.placeholder.is_none()); + } + + #[test] + fn default_behavior_false_not_allowed() { + let r = PrepareRenameResponse::from_lsp_value(&json!({ "defaultBehavior": false })); + assert!(!r.allowed); + } + + #[test] + fn unknown_shape_not_allowed() { + let r = PrepareRenameResponse::from_lsp_value(&json!({ "weird": 1 })); + assert!(!r.allowed); + } + + #[test] + fn store_set_get_clear() { + let mut s = PrepareRenameStore::new(); + let key = PrepareRenameKey::new("1", "file:///a"); + s.set( + key.clone(), + PrepareRenameResponse { + allowed: true, + placeholder: Some("x".into()), + range: Some((0, 0, 0, 1)), + }, + ); + assert!(s.get(&key).unwrap().allowed); + s.clear(&key); + assert!(s.get(&key).is_none()); + } +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 73ac418..a21a2ea 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -4087,6 +4087,195 @@ fn m4_21_semantic_tokens_full_then_delta() { assert_eq!(third_delta, vec![3, 0, 9, 1, 0]); } +/// T M4.5 — rename with `textDocument/prepareRename`. The `prepare` +/// fake advertises `renameProvider.prepareProvider` and answers +/// prepareRename with a `{ range, placeholder }`. `pmacs.lsp.rename` +/// must do the prepare round-trip *before* the prompt opens (so the +/// minibuffer isn't active synchronously), pre-fill the placeholder, +/// then apply the rename on accept. +#[test] +fn m4_22_rename_prepare_gates_and_prefills() { + use pmacs::editor::EditorState; + + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("a.rs"); + std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); + let a_disp = a_path.display().to_string(); + + let mut state = EditorState::new(); + let fake = fake_lsp_path(); + state + .lua_host + .lua() + .load(format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ PMACS_FAKE_LSP_MODE = 'prepare' }}, + }}" + )) + .exec() + .expect("override rust config"); + state + .lua_host + .lua() + .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) + .exec() + .expect("open a.rs"); + assert!( + pump_lua_flag( + &mut state, + "(function() for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end return false end)()", + 5, + ), + "fake never initialized" + ); + + state + .lua_host + .lua() + .load("pmacs.lsp.rename()") + .exec() + .expect("invoke rename"); + + // With prepareRename, the prompt is NOT open synchronously — it + // opens only after the async prepare round-trip resolves. + assert!( + !state + .lua_host + .lua() + .load("return pmacs.minibuffer.is_active()") + .eval::() + .unwrap(), + "prompt must wait for the prepareRename round-trip" + ); + assert!( + pump_lua_flag(&mut state, "pmacs.minibuffer.is_active()", 5), + "prepareRename allowed → prompt should have opened" + ); + + // Placeholder pre-filled from the server's prepare response. + let initial: String = state + .lua_host + .lua() + .load("return pmacs.minibuffer.contents()") + .eval() + .unwrap(); + assert_eq!( + initial, "foo", + "prompt should be pre-filled with the placeholder" + ); + + state + .lua_host + .lua() + .load("pmacs.minibuffer.set_contents('BAR'); pmacs.minibuffer.accept()") + .exec() + .expect("accept rename"); + assert!( + pump_lua_flag( + &mut state, + "(function() local b = pmacs.window.buffer() \ + return b ~= nil and b:slice(0, b:len()):find('BAR', 1, true) ~= nil end)()", + 5, + ), + "rename never applied after prepare" + ); + let a_text: String = state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .unwrap(); + assert_eq!(a_text, "abcBARxyz\n"); +} + +/// T M4.5 — prepareRename refusal. The `preprefuse` fake answers +/// prepareRename with `null`; `pmacs.lsp.rename` must abort without +/// ever opening a prompt and leave the buffer untouched. +#[test] +fn m4_23_rename_prepare_refusal_aborts() { + use pmacs::editor::EditorState; + + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("a.rs"); + std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); + let a_disp = a_path.display().to_string(); + + let mut state = EditorState::new(); + let fake = fake_lsp_path(); + state + .lua_host + .lua() + .load(format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ PMACS_FAKE_LSP_MODE = 'preprefuse' }}, + }}" + )) + .exec() + .expect("override rust config"); + state + .lua_host + .lua() + .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) + .exec() + .expect("open a.rs"); + assert!( + pump_lua_flag( + &mut state, + "(function() for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end return false end)()", + 5, + ), + "fake never initialized" + ); + + state + .lua_host + .lua() + .load("pmacs.lsp.rename()") + .exec() + .expect("invoke rename"); + + // Wait until the refusal actually landed (allowed == false), so + // we're asserting after the abort path ran, not before. + let refused = format!( + "(function() \ + local sid \ + for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then sid=r.id end \ + end \ + if not sid then return false end \ + local pr = pmacs.prepare_rename.result(sid, 'file://{a_disp}') \ + return pr ~= nil and pr.allowed == false \ + end)()" + ); + assert!( + pump_lua_flag(&mut state, &refused, 5), + "prepareRename refusal never landed" + ); + + assert!( + !state + .lua_host + .lua() + .load("return pmacs.minibuffer.is_active()") + .eval::() + .unwrap(), + "a refused prepareRename must not open the prompt" + ); + let a_text: String = state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .unwrap(); + assert_eq!(a_text, "abcfooxyz\n", "buffer must be untouched"); +} + /// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the /// hooks are defined, the namespace tables exist, the user-facing /// commands are registered with the command registry, and the default