Merge pull request #27 from levineuwirth/lsp-prepare-rename

T M4.5: textDocument/prepareRename (backlog item 3)
This commit is contained in:
Levi Neuwirth 2026-05-19 18:24:35 +00:00 committed by GitHub
commit 64427befac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 671 additions and 53 deletions

View File

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

View File

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

View File

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

View File

@ -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<String>,
line: u32,
col: u32,
) -> Result<JobId, String> {
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.

View File

@ -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<Table> {
let t = lua.create_table_with_capacity(0, 4)?;
t.set("title", a.title.as_str())?;

228
src/prepare_rename.rs Normal file
View File

@ -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<String>,
/// 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<PrepareRenameKey, PrepareRenameResponse>,
}
/// 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<String>, uri: impl Into<String>) -> 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<Mutex<PrepareRenameStore>>;
/// 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());
}
}

View File

@ -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::<bool>()
.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::<bool>()
.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