T M4.5 L2: WorkspaceEdit applier + textDocument/rename

Second cross-file layer on the L1 foundations: a multi-file
WorkspaceEdit applier and an LSP rename UX driving it.

- src/rename.rs: parse a WorkspaceEdit (both `changes` map and
  `documentChanges`, the latter preferred per spec; AnnotatedTextEdit
  handled; create/rename/delete resource ops counted into
  `unsupported_ops` for L4) into per-file TextEdit lists. RenameStore
  keyed by the request's origin URI. 6 unit tests.
- src/lsp.rs: rename_store + accessor, ResponseRoute::Rename,
  request_rename, and the textDocument.rename client capability
  (prepareSupport=false — L2 renames from the cursor position).
- src/lua_bindings.rs: _request_rename_raw + pmacs.rename.{file_edits,
  unsupported,clear}.
- pmacs_fake_lsp.rs: textDocument/rename arm; `rename` mode returns a
  2-file documentChanges plus a create resource op.
- builtin/runtime/lsp.lua: apply_workspace_edit (preflight rejects
  unresolvable URIs before mutating anything; per-file reverse-sorted
  application; origin buffer restored), pmacs.lsp.rename with a
  minibuffer prompt, lsp.rename command, C-c r keybind.
- tests/m4_acceptance.rs: m4_13 drives rename end-to-end through the
  minibuffer and asserts both files mutated + origin restored.

Gates: lib 1268/0, m4 68/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m11_5 (--features crdt) 2/0; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-19 10:42:03 -04:00
parent 3a7b8865c8
commit cbfb7f5e71
7 changed files with 782 additions and 7 deletions

View File

@ -4,14 +4,15 @@
-- * Declarative server config (`pmacs.lsp.config[language]`).
-- * Auto-attach + did_open / did_change / did_close on buffer events.
-- * `pmacs.lsp.go_to_definition` / `pmacs.lsp.format_buffer` /
-- `pmacs.lsp.hover_at_cursor` / `pmacs.lsp.signature_help_at_cursor`,
-- bound to default chords below.
-- `pmacs.lsp.hover_at_cursor` / `pmacs.lsp.signature_help_at_cursor`
-- / `pmacs.lsp.rename`, bound to default chords below.
--
-- v0.1 scope: one server per language across all buffers; same-file
-- definition jumps only; synchronous request → poll → react cycle
-- (sub-second for hot servers). Cross-file navigation, async-await
-- coroutines, rename, code actions, inlay hints, semantic tokens, and
-- file-watch capability registration are all v0.2 work.
-- Scope: one server per language across all buffers; async-await
-- request/react (the editor never blocks). Cross-file go-to-definition
-- and a multi-file rename / WorkspaceEdit applier have landed (L1/L2).
-- Code actions, inlay hints, semantic tokens, resource-op edits
-- (create/rename/delete file), and file-watch capability registration
-- are later layers.
pmacs.lsp = pmacs.lsp or {}
pmacs.lsp.config = pmacs.lsp.config or {}
@ -263,6 +264,7 @@ pmacs.lsp.request_implementation = wrap_request(pmacs.lsp._request_implementatio
pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_symbol_raw)
pmacs.lsp.request_workspace_symbol = wrap_request(pmacs.lsp._request_workspace_symbol_raw)
pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_highlight_raw)
pmacs.lsp.request_rename = wrap_request(pmacs.lsp._request_rename_raw)
-- Render an `:await()` failure into a modeline-friendly reason.
-- `Handle:await()` raises `{ tag = "cancelled", ... }` when the
@ -344,6 +346,46 @@ local function apply_text_edits(edits)
return #resolved
end
-- T M4.5 L2 — apply a parsed LSP `WorkspaceEdit` (`pmacs.rename`'s
-- per-file shape: `{ { uri = , edits = { … } }, … }`) across however
-- many files it touches.
--
-- Atomicity: a true cross-buffer transaction is out of scope here, so
-- the applier instead refuses to mutate *anything* unless every URI
-- with edits resolves to a real file path first (`path_for_uri`). A
-- rename that names an `untitled:`/non-file document aborts cleanly
-- with the origin buffer untouched, rather than half-applying.
--
-- Per file the edits are applied through `apply_text_edits`, which
-- resolves offsets against that buffer's *original* text and applies
-- in reverse-start order — correct because each file's edits are
-- independent and `find_or_open` makes the target the active buffer
-- before its batch runs. The buffer the user invoked from is restored
-- last. Returns `total_edits, file_count` on success, or
-- `nil, message` if the preflight rejected the edit.
local function apply_workspace_edit(file_edits)
local plan = {}
for _, fe in ipairs(file_edits or {}) do
if fe.edits and #fe.edits > 0 then
local path = pmacs.lsp.path_for_uri(fe.uri)
if not path then
return nil, "cannot resolve " .. tostring(fe.uri)
end
table.insert(plan, { path = path, edits = fe.edits })
end
end
if #plan == 0 then return 0, 0 end
local origin = active_buffer_path()
local total = 0
for _, item in ipairs(plan) do
pmacs.buffer.find_or_open(item.path)
total = total + apply_text_edits(item.edits)
end
-- Return the user to where they invoked rename from.
if origin then pmacs.buffer.find_or_open(origin) end
return total, #plan
end
-- Commands ----------------------------------------------------------------
-- Each command captures the cursor/target at invocation time, then
@ -503,6 +545,66 @@ 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.
function pmacs.lsp.rename()
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.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()
end)
if not ok then
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
return
end
local fe = pmacs.rename.file_edits(rec.server, rec.uri)
local skipped = pmacs.rename.unsupported(rec.server, rec.uri)
if (not fe or #fe == 0) and skipped == 0 then
pmacs.editor.set_status("LSP: rename produced no edits")
return
end
local n, info = apply_workspace_edit(fe)
if not n then
-- Preflight rejected it; nothing was mutated.
pmacs.editor.set_status("LSP: rename aborted: " .. tostring(info))
return
end
local msg = string.format(
"LSP: renamed — %d edit%s across %d file%s",
n, (n == 1 and "" or "s"),
info, (info == 1 and "" or "s"))
if skipped > 0 then
msg = msg .. string.format(
" (%d unsupported op%s skipped)",
skipped, (skipped == 1 and "" or "s"))
end
pmacs.editor.set_status(msg)
end)
end,
}
end
function pmacs.lsp.hover_at_cursor()
local rec = attached_for_active()
if not rec then
@ -598,6 +700,12 @@ pmacs.command.define {
fn = pmacs.lsp.document_symbols,
}
pmacs.command.define {
name = "lsp.rename",
description = "Rename the symbol under the cursor across the workspace (LSP).",
fn = pmacs.lsp.rename,
}
-- T M4.5 L1 — unwind the cross-file jump ring. Pairs with the
-- `pmacs.editor.push_jump()` every navigation action records before
-- it moves the cursor.
@ -619,6 +727,7 @@ pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-d
pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" }
pmacs.keymap.bind { scope = "global", sequence = "M-,", command = "lsp.jump-back" }
pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" }
pmacs.keymap.bind { scope = "global", sequence = "C-c r", command = "lsp.rename" }
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

@ -400,6 +400,60 @@ fn main() {
});
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
// the requested `newName` (so the test can assert the
// buffer text changed). In `rename` mode a *second*
// file URI is taken from `PMACS_FAKE_LSP_RENAME_URI`
// and given the same edit, plus a `create` resource
// op — exercising the cross-file applier and the
// unsupported-op count. Otherwise the edit is
// single-file (the request's own document).
let uri = params
.get("textDocument")
.and_then(|t| t.get("uri"))
.cloned()
.unwrap_or(serde_json::Value::Null);
let new_name = params
.get("newName")
.and_then(serde_json::Value::as_str)
.unwrap_or("renamed")
.to_owned();
let edit = serde_json::json!([{
"range": {
"start": { "line": 0, "character": 3 },
"end": { "line": 0, "character": 6 }
},
"newText": new_name
}]);
let workspace_edit = if mode == "rename" {
let second = std::env::var("PMACS_FAKE_LSP_RENAME_URI").unwrap_or_default();
serde_json::json!({
"documentChanges": [
{
"textDocument": { "uri": uri, "version": 1 },
"edits": edit.clone()
},
{
"textDocument": { "uri": second, "version": 1 },
"edits": edit.clone()
},
{ "kind": "create", "uri": "file:///tmp/pmacs-fake-created.rs" }
]
})
} else {
let mut changes = serde_json::Map::new();
changes.insert(uri.as_str().unwrap_or("").to_owned(), edit);
serde_json::json!({ "changes": changes })
};
let resp = serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"result": workspace_edit
});
write_frame(&mut stdout, &resp);
}
// T M4.5 symbols/highlight. documentSymbol returns the
// *hierarchical* DocumentSymbol shape (exercises tree
// flatten + depth + parent); workspace/symbol the flat

View File

@ -91,6 +91,7 @@ pub mod process;
pub mod project;
pub mod project_index;
pub mod protocol;
pub mod rename;
pub mod rope;
// T M11.5 — the headless semantic consumer composes BufferMirror +
// optimistic (both `crdt`-gated) and is only meaningful on a

View File

@ -756,6 +756,9 @@ pub struct LspManager {
/// T M4.12 formatting store. Populated when a
/// `textDocument/formatting` response lands.
formatting_store: crate::formatting::SharedFormattingStore,
/// 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,
/// Per-server request id → response routing target.
/// `request_completion` etc. record an entry here; `handle_response`
/// consumes it to absorb the response into the correct store.
@ -813,6 +816,9 @@ enum ResponseRoute {
/// Absorb response into [`crate::formatting::FormattingStore`] at
/// `(server, uri)`.
Formatting { uri: String },
/// Absorb a `textDocument/rename` `WorkspaceEdit` into
/// [`crate::rename::RenameStore`] at `(server, origin uri)`.
Rename { uri: String },
/// Absorb a Location-shaped nav response (references / declaration
/// / typeDefinition / implementation) into
/// [`crate::locations::LocationsStore`] at `(server, uri, kind)`.
@ -854,6 +860,7 @@ impl ResponseRoute {
| ResponseRoute::Signature { uri }
| ResponseRoute::Definition { uri }
| ResponseRoute::Formatting { uri }
| ResponseRoute::Rename { uri }
| ResponseRoute::Locations { uri, .. }
| ResponseRoute::DocumentSymbol { uri }
| ResponseRoute::DocumentHighlight { uri } => uri,
@ -935,6 +942,7 @@ impl LspManager {
symbol_store: crate::symbol::make_shared_store(),
document_highlight_store: crate::document_highlight::make_shared_store(),
formatting_store: crate::formatting::make_shared_store(),
rename_store: crate::rename::make_shared_store(),
pending_routes: HashMap::new(),
status_tracker: crate::lsp_status::LspStatusTracker::new(),
project_servers: HashMap::new(),
@ -1001,6 +1009,12 @@ impl LspManager {
self.formatting_store.clone()
}
/// Shared rename / `WorkspaceEdit` store (T M4.5 L2).
#[must_use]
pub fn rename_store(&self) -> crate::rename::SharedRenameStore {
self.rename_store.clone()
}
/// T M4.8: per-server status snapshot, derived from the LSP event
/// stream. The modeline reads its label from this.
#[must_use]
@ -1720,6 +1734,32 @@ impl LspManager {
Ok(job_id)
}
/// Send `textDocument/rename` for the symbol at `(line, col)` in
/// `uri`, requesting `new_name`. The response is a `WorkspaceEdit`
/// (possibly multi-file); it is absorbed into the rename store
/// keyed by the *origin* `uri`. Returns the async-runtime
/// [`JobId`] the response will settle.
pub fn request_rename(
&mut self,
sid: LspServerId,
uri: impl Into<String>,
line: u32,
col: u32,
new_name: impl Into<String>,
) -> Result<JobId, String> {
let uri = uri.into();
let params = json!({
"textDocument": { "uri": uri.clone() },
"position": { "line": line, "character": col },
"newName": new_name.into(),
});
let req_id = self.send_request(sid, "textDocument/rename", params)?;
let job_id = self.register_awaiter(sid, req_id, "textDocument/rename", &uri);
self.pending_routes
.insert((sid, req_id), ResponseRoute::Rename { uri });
Ok(job_id)
}
/// Reply to a server-initiated request.
pub fn send_response(
&mut self,
@ -2297,6 +2337,15 @@ impl LspManager {
.expect("formatting store mutex poisoned");
guard.set(key, resp);
}
ResponseRoute::Rename { uri } => {
let resp = crate::rename::WorkspaceEditResponse::from_lsp_value(result);
let key = crate::rename::RenameKey::new(server_key, uri.clone());
let mut guard = self
.rename_store
.lock()
.expect("rename store mutex poisoned");
guard.set(key, resp);
}
}
}
@ -2715,6 +2764,11 @@ 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 },
"publishDiagnostics": { "relatedInformation": true },
},
})

View File

@ -7380,6 +7380,29 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
)?;
}
{
let m = manager.clone();
lsp_mod.set(
"_request_rename_raw",
lua.create_function(
move |_,
(id, uri, line, col, new_name): (
LspServerIdLua,
String,
u32,
u32,
String,
)| {
let job_id = m
.borrow_mut()
.request_rename(id.0, uri, line, col, new_name)
.map_err(mlua::Error::external)?;
Ok(job_id)
},
)?,
)?;
}
{
let m = manager.clone();
lsp_mod.set(
@ -7615,6 +7638,7 @@ pub fn make_lsp_manager(
install_symbol(lua, &manager)?;
install_document_highlight(lua, &manager)?;
install_formatting(lua, &manager)?;
install_rename(lua, &manager)?;
Ok(manager)
}
@ -8415,6 +8439,7 @@ use crate::document_highlight::{DocumentHighlightKey, Highlight};
use crate::formatting::{FormattingKey, FormattingResponse, TextEdit};
use crate::hover::{Hover, HoverKey};
use crate::locations::{LocationKind, LocationsKey};
use crate::rename::{RenameKey, WorkspaceEditResponse};
use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter};
use crate::symbol::{Symbol as LspSymbol, SymbolKey};
@ -9086,6 +9111,77 @@ pub fn install_formatting(lua: &Lua, manager: &SharedLspManager) -> mlua::Result
Ok(())
}
fn workspace_edit_to_lua(lua: &Lua, r: &WorkspaceEditResponse) -> mlua::Result<Table> {
let files = lua.create_table_with_capacity(r.files.len(), 0)?;
for (i, f) in r.files.iter().enumerate() {
let entry = lua.create_table_with_capacity(0, 2)?;
entry.set("uri", f.uri.as_str())?;
let edits = lua.create_table_with_capacity(f.edits.len(), 0)?;
for (j, e) in f.edits.iter().enumerate() {
edits.set(j + 1, text_edit_to_lua(lua, e)?)?;
}
entry.set("edits", edits)?;
files.set(i + 1, entry)?;
}
Ok(files)
}
/// Install `pmacs.rename.*` (T M4.5 L2). `file_edits(sid, uri)`
/// returns the parsed `WorkspaceEdit` as `{ { uri = , edits = { … } },
/// … }` (per-file, deterministic order); `unsupported(sid, uri)` is
/// the count of `create`/`rename`/`delete` file ops the L2 applier
/// skips; `clear(sid, uri)` drops the entry.
pub fn install_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(
"file_edits",
lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| {
let store_handle = mgr.borrow().rename_store();
let guard = store_handle.lock().expect("rename store mutex poisoned");
let key = RenameKey::new(id.0.raw().to_string(), uri);
if let Some(r) = guard.get(&key) {
Ok(Value::Table(workspace_edit_to_lua(lua, r)?))
} else {
Ok(Value::Table(lua.create_table_with_capacity(0, 0)?))
}
})?,
)?;
}
{
let mgr = manager.clone();
m.set(
"unsupported",
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
let store_handle = mgr.borrow().rename_store();
let guard = store_handle.lock().expect("rename store mutex poisoned");
let key = RenameKey::new(id.0.raw().to_string(), uri);
Ok(guard.get(&key).map_or(0, |r| r.unsupported_ops))
})?,
)?;
}
{
let mgr = manager.clone();
m.set(
"clear",
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
let store_handle = mgr.borrow().rename_store();
let mut guard = store_handle.lock().expect("rename store mutex poisoned");
guard.clear(&RenameKey::new(id.0.raw().to_string(), uri));
Ok(())
})?,
)?;
}
pmacs.set("rename", m)?;
Ok(())
}
// ---------------------------------------------------------------------------
// pmacs.project: project model (T M4.9)
// ---------------------------------------------------------------------------

331
src/rename.rs Normal file
View File

@ -0,0 +1,331 @@
// rename.rs --- T M4.5 L2 LSP-backed rename / WorkspaceEdit state.
//! `textDocument/rename` response state.
//!
//! A rename answer is an LSP [`WorkspaceEdit`], which may touch many
//! files. This module parses both edit carriers —
//!
//! * `changes`: `{ uri: TextEdit[] }`
//! * `documentChanges`: `(TextDocumentEdit | resource-op)[]`
//!
//! — into a flat, per-file edit list ([`WorkspaceEditResponse`]). The
//! [`crate::formatting::TextEdit`] shape is reused verbatim (same
//! zero-based, UTF-16-column coordinates). Resource operations
//! (`create` / `rename` / `delete` file) are L4 work; they are skipped
//! here and counted in [`WorkspaceEditResponse::unsupported_ops`] so
//! the Lua surface can warn rather than silently drop a partial rename.
//!
//! Like [`crate::formatting`], there is no Rust-side editor mutation:
//! Lua reads the per-file lists and drives `pmacs.buffer.*` /
//! `pmacs.editor.*` so the application strategy stays configurable.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use crate::formatting::TextEdit;
/// Edits the server wants applied to one document.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileEdits {
/// Target document URI.
pub uri: String,
/// Edits for this document, in server order. Callers apply in
/// reverse-start order so earlier offsets stay valid.
pub edits: Vec<TextEdit>,
}
/// A parsed `WorkspaceEdit`: per-file edit lists plus a count of
/// resource operations we deliberately did not apply (L4).
#[derive(Clone, Debug, Default)]
pub struct WorkspaceEditResponse {
/// One entry per touched document.
pub files: Vec<FileEdits>,
/// Number of `create` / `rename` / `delete` file operations the
/// server requested that this layer does not yet apply.
pub unsupported_ops: usize,
}
impl WorkspaceEditResponse {
/// Parse `WorkspaceEdit | null`.
///
/// Per the LSP spec `documentChanges` supersedes `changes` when
/// both are present, so it is preferred. A `null` / shapeless
/// result yields an empty response (rename produced nothing).
#[must_use]
pub fn from_lsp_value(v: &Value) -> Self {
if let Some(dc) = v.get("documentChanges").and_then(Value::as_array) {
return Self::from_document_changes(dc);
}
if let Some(changes) = v.get("changes").and_then(Value::as_object) {
let mut files = Vec::with_capacity(changes.len());
for (uri, edits) in changes {
files.push(FileEdits {
uri: uri.clone(),
edits: parse_edit_array(edits),
});
}
// Object iteration order is unspecified; sort by URI so the
// applier (and tests) see a deterministic sequence.
files.sort_by(|a, b| a.uri.cmp(&b.uri));
return Self {
files,
unsupported_ops: 0,
};
}
Self::default()
}
fn from_document_changes(dc: &[Value]) -> Self {
let mut files = Vec::new();
let mut unsupported_ops = 0;
for entry in dc {
// A resource operation is tagged with `kind`; a
// TextDocumentEdit has a `textDocument` + `edits`.
if entry.get("kind").and_then(Value::as_str).is_some() {
unsupported_ops += 1;
continue;
}
let Some(uri) = entry
.get("textDocument")
.and_then(|t| t.get("uri"))
.and_then(Value::as_str)
else {
continue;
};
let edits = entry.get("edits").map(parse_edit_array).unwrap_or_default();
files.push(FileEdits {
uri: uri.to_owned(),
edits,
});
}
Self {
files,
unsupported_ops,
}
}
/// True iff there is nothing to apply and nothing was skipped.
#[must_use]
pub fn is_empty(&self) -> bool {
self.files.iter().all(|f| f.edits.is_empty()) && self.unsupported_ops == 0
}
/// Total edits across every file.
#[must_use]
pub fn edit_count(&self) -> usize {
self.files.iter().map(|f| f.edits.len()).sum()
}
}
/// Parse a `(TextEdit | AnnotatedTextEdit)[]` value into edits,
/// dropping malformed entries. `AnnotatedTextEdit` is a `TextEdit`
/// with an extra `annotationId`; the range/newText parse identically.
fn parse_edit_array(v: &Value) -> Vec<TextEdit> {
let Some(arr) = v.as_array() else {
return Vec::new();
};
let mut out = Vec::with_capacity(arr.len());
for item in arr {
if let Some(e) = parse_text_edit(item) {
out.push(e);
}
}
out
}
fn parse_text_edit(v: &Value) -> Option<TextEdit> {
let range = v.get("range")?;
let start = range.get("start")?;
let end = range.get("end")?;
let new_text = v.get("newText")?.as_str()?.to_owned();
Some(TextEdit {
start_line: start.get("line")?.as_u64()? as u32,
start_col: start.get("character")?.as_u64()? as u32,
end_line: end.get("line")?.as_u64()? as u32,
end_col: end.get("character")?.as_u64()? as u32,
new_text,
})
}
/// Per-server, per-origin-uri rename response state. The key URI is
/// the document the rename was *requested* on (the symbol's home
/// file), not the files the edit happens to touch.
#[derive(Default)]
pub struct RenameStore {
by_key: HashMap<RenameKey, WorkspaceEditResponse>,
}
/// Key into [`RenameStore`].
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct RenameKey {
/// Decimal LSP server id.
pub server: String,
/// The URI the rename was requested on.
pub uri: String,
}
impl RenameKey {
/// 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 RenameStore {
/// Empty store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Replace the response at `key`.
pub fn set(&mut self, key: RenameKey, response: WorkspaceEditResponse) {
self.by_key.insert(key, response);
}
/// Drop the entry at `key`.
pub fn clear(&mut self, key: &RenameKey) {
self.by_key.remove(key);
}
/// Look up the entry at `key`.
#[must_use]
pub fn get(&self, key: &RenameKey) -> Option<&WorkspaceEditResponse> {
self.by_key.get(key)
}
}
/// Cheaply-cloneable shared handle.
pub type SharedRenameStore = Arc<Mutex<RenameStore>>;
/// Build a fresh shared store.
#[must_use]
pub fn make_shared_store() -> SharedRenameStore {
Arc::new(Mutex::new(RenameStore::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn one_edit(new_text: &str) -> Value {
json!({
"range": {
"start": { "line": 0, "character": 3 },
"end": { "line": 0, "character": 6 }
},
"newText": new_text
})
}
#[test]
fn parses_changes_map_sorted_by_uri() {
let v = json!({
"changes": {
"file:///b.rs": [one_edit("Y")],
"file:///a.rs": [one_edit("X"), one_edit("Z")]
}
});
let r = WorkspaceEditResponse::from_lsp_value(&v);
assert_eq!(r.files.len(), 2);
assert_eq!(r.files[0].uri, "file:///a.rs");
assert_eq!(r.files[0].edits.len(), 2);
assert_eq!(r.files[1].uri, "file:///b.rs");
assert_eq!(r.edit_count(), 3);
assert_eq!(r.unsupported_ops, 0);
}
#[test]
fn parses_document_changes_and_prefers_it_over_changes() {
let v = json!({
"changes": { "file:///ignored.rs": [one_edit("NO")] },
"documentChanges": [
{
"textDocument": { "uri": "file:///a.rs", "version": 1 },
"edits": [one_edit("A")]
}
]
});
let r = WorkspaceEditResponse::from_lsp_value(&v);
assert_eq!(r.files.len(), 1);
assert_eq!(r.files[0].uri, "file:///a.rs");
assert_eq!(r.files[0].edits[0].new_text, "A");
}
#[test]
fn resource_ops_are_counted_not_applied() {
let v = json!({
"documentChanges": [
{ "kind": "create", "uri": "file:///new.rs" },
{
"textDocument": { "uri": "file:///a.rs", "version": 2 },
"edits": [one_edit("A")]
},
{ "kind": "rename", "oldUri": "file:///a.rs", "newUri": "file:///c.rs" }
]
});
let r = WorkspaceEditResponse::from_lsp_value(&v);
assert_eq!(r.files.len(), 1);
assert_eq!(r.unsupported_ops, 2);
assert!(!r.is_empty());
}
#[test]
fn null_response_is_empty() {
let r = WorkspaceEditResponse::from_lsp_value(&Value::Null);
assert!(r.is_empty());
assert_eq!(r.edit_count(), 0);
}
#[test]
fn annotated_text_edit_parses_like_plain() {
let v = json!({
"documentChanges": [{
"textDocument": { "uri": "file:///a.rs", "version": 1 },
"edits": [{
"range": {
"start": { "line": 2, "character": 0 },
"end": { "line": 2, "character": 4 }
},
"newText": "Q",
"annotationId": "ann1"
}]
}]
});
let r = WorkspaceEditResponse::from_lsp_value(&v);
assert_eq!(r.files[0].edits[0].new_text, "Q");
assert_eq!(r.files[0].edits[0].start_line, 2);
}
#[test]
fn store_set_get_clear() {
let mut s = RenameStore::new();
let key = RenameKey::new("1", "file:///a");
s.set(
key.clone(),
WorkspaceEditResponse {
files: vec![FileEdits {
uri: "file:///a".into(),
edits: vec![TextEdit {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 1,
new_text: "x".into(),
}],
}],
unsupported_ops: 0,
},
);
assert_eq!(s.get(&key).unwrap().files.len(), 1);
s.clear(&key);
assert!(s.get(&key).is_none());
}
}

View File

@ -3424,6 +3424,136 @@ fn m4_12_cross_file_go_to_definition_and_jump_back() {
);
}
/// T M4.5 L2 — cross-file rename end to end through the default
/// bundle. The `rename` fake returns a `WorkspaceEdit` whose
/// `documentChanges` touch *two* files (the origin plus an env-named
/// second URI) and include one resource op. `pmacs.lsp.rename` must
/// prompt, send `textDocument/rename`, await the `WorkspaceEdit`, then
/// apply the per-file edits across both buffers, count the skipped
/// resource op, and restore the origin buffer.
#[test]
fn m4_13_rename_applies_cross_file_workspace_edit() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let a_path = dir.path().join("a.rs");
let b_path = dir.path().join("b.rs");
// The fake's edit replaces line-0 cols 3..6; "foo" sits there.
std::fs::write(&a_path, b"abcfooxyz\n").expect("write a");
std::fs::write(&b_path, b"abcfooxyz\n").expect("write b");
let a_disp = a_path.display().to_string();
let b_disp = b_path.display().to_string();
let b_uri = format!("file://{b_disp}");
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 = 'rename',
PMACS_FAKE_LSP_RENAME_URI = '{b_uri}',
}},
}}"
))
.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"
);
// Open the rename prompt, type the new name, accept it. `accept`
// invokes the `on_accept` callback, which spawns the async
// request/apply coroutine.
state
.lua_host
.lua()
.load("pmacs.lsp.rename()")
.exec()
.expect("invoke rename");
assert!(
state
.lua_host
.lua()
.load("return pmacs.minibuffer.is_active()")
.eval::<bool>()
.unwrap(),
"rename should have opened a minibuffer prompt"
);
state
.lua_host
.lua()
.load("pmacs.minibuffer.set_contents('BAR'); pmacs.minibuffer.accept()")
.exec()
.expect("accept rename name");
// Completion signal: the active (origin) buffer's text now carries
// the rename — proves the request landed, the edit applied, and
// the origin buffer was restored.
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 to the origin buffer"
);
// Origin buffer restored.
let active: Option<String> = state
.lua_host
.lua()
.load("return pmacs.editor.file_path()")
.eval()
.unwrap();
assert_eq!(
active.as_deref(),
Some(a_disp.as_str()),
"rename must restore the buffer it was invoked from"
);
// Origin file edited in place.
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", "a.rs should be renamed");
// The *second* file in the WorkspaceEdit was edited too.
let b_text: String = state
.lua_host
.lua()
.load(format!(
"pmacs.buffer.find_or_open('{b_disp}') \
local b = pmacs.window.buffer() return b:slice(0, b:len())"
))
.eval()
.unwrap();
assert_eq!(b_text, "abcBARxyz\n", "b.rs should be renamed cross-file");
}
/// 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