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:
parent
3a7b8865c8
commit
cbfb7f5e71
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
54
src/lsp.rs
54
src/lsp.rs
|
|
@ -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 },
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue