T M4.5 L4: WorkspaceEdit resource ops (create/rename/delete file)

Final cross-file layer: filesystem resource operations in
documentChanges, applied in server order alongside text edits.

- src/rename.rs: replace the files/unsupported_ops split with a
  single ordered Vec<WorkspaceOp> (Edit | Create | Rename | Delete
  with options). Order preserved exactly as sent so create-before-
  edit works; the `changes` map still emits URI-sorted edit ops.
  files()/is_empty()/edit_count()/resource_op_count() helpers.
  Tests reworked to the ops model.
- src/code_action.rs: adapt to the ops model (has_edit unchanged).
- src/lua_bindings.rs: workspace_ops_to_lua (ordered, op-tagged) +
  file_edits_to_lua (back-compat); pmacs.rename.ops;
  _parse_workspace_edit -> { ops }; code-action edit is ops; new
  pmacs.buffer.apply_resource_op doing the filesystem op plus
  buffer-registry reconciliation (rename rebinds an open buffer's
  path; delete removes its buffer; create makes parent dirs and
  honours overwrite/ignoreIfExists).
- builtin/runtime/lsp.lua: apply_workspace_edit rewritten to walk
  the ordered ops, preflight-resolve every URI before mutating
  anything, run text edits via apply_text_edits and resource ops
  via apply_resource_op, restore origin best-effort. Returns
  edits, files, resource_ops; status messages updated.
- pmacs_fake_lsp.rs: drop the stray /tmp create from `rename`
  mode; add a `resourceops` mode whose executeCommand->applyEdit
  returns create -> edit-created -> rename -> delete.
- tests/m4_acceptance.rs: m4_15 drives all four ops through the
  applyEdit pump and asserts disk effects + create-before-edit
  ordering.

Gates: lib 1274/0, m4 70/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 12:31:35 -04:00
parent 21ff334bcc
commit a865dc7a76
6 changed files with 646 additions and 181 deletions

View File

@ -12,9 +12,10 @@
-- 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
-- `workspace/applyEdit` (L3). Inlay hints, semantic tokens,
-- resource-op edits (create/rename/delete file), and file-watch
-- capability registration are later layers.
-- `workspace/applyEdit` (L3), ordered resource-op edits
-- (create/rename/delete file) with buffer-registry reconciliation
-- (L4). Inlay hints, semantic tokens, and file-watch capability
-- registration are later layers.
pmacs.lsp = pmacs.lsp or {}
pmacs.lsp.config = pmacs.lsp.config or {}
@ -350,44 +351,80 @@ 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.
-- T M4.5 L2/L4 — apply a parsed LSP `WorkspaceEdit` given as the
-- ordered op list `pmacs.rename.ops` / `code_action.edit` /
-- `_parse_workspace_edit` hand back: each entry is tagged `op` =
-- "edit" | "create" | "rename" | "delete". Order is the server's and
-- is honoured exactly, because the spec sequences ops (a `create`
-- must precede the `edit` that fills the new file).
--
-- 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.
-- Atomicity: a true cross-buffer/disk transaction is out of scope, so
-- the applier refuses to mutate *anything* unless every URI it
-- touches resolves to a real file path first (`path_for_uri`). An op
-- naming an `untitled:`/non-file document aborts the whole edit
-- cleanly, 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
-- Text edits go through `apply_text_edits` (offsets resolved against
-- that buffer's *original* text, applied reverse-start) after
-- `find_or_open` makes the target active. Resource ops go through
-- `pmacs.buffer.apply_resource_op` (filesystem + buffer-registry
-- reconciliation). The buffer the user invoked from is restored last
-- (best-effort: it may itself have been renamed/deleted). Returns
-- `edit_count, file_count, resource_op_count` on success, or
-- `nil, message` if the preflight rejected the edit.
local function apply_workspace_edit(file_edits)
local function apply_workspace_edit(ops)
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)
for _, op in ipairs(ops or {}) do
if op.op == "edit" then
if op.edits and #op.edits > 0 then
local path = pmacs.lsp.path_for_uri(op.uri)
if not path then return nil, "cannot resolve " .. tostring(op.uri) end
plan[#plan + 1] = { kind = "edit", path = path, edits = op.edits }
end
table.insert(plan, { path = path, edits = fe.edits })
elseif op.op == "create" then
local path = pmacs.lsp.path_for_uri(op.uri)
if not path then return nil, "cannot resolve " .. tostring(op.uri) end
plan[#plan + 1] = {
kind = "create", path = path,
overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists,
}
elseif op.op == "rename" then
local from = pmacs.lsp.path_for_uri(op.old_uri)
local to = pmacs.lsp.path_for_uri(op.new_uri)
if not from or not to then
return nil, "cannot resolve rename " ..
tostring(op.old_uri) .. " -> " .. tostring(op.new_uri)
end
plan[#plan + 1] = {
kind = "rename", old_path = from, new_path = to,
overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists,
}
elseif op.op == "delete" then
local path = pmacs.lsp.path_for_uri(op.uri)
if not path then return nil, "cannot resolve " .. tostring(op.uri) end
plan[#plan + 1] = {
kind = "delete", path = path,
recursive = op.recursive, ignore_if_not_exists = op.ignore_if_not_exists,
}
end
end
if #plan == 0 then return 0, 0 end
if #plan == 0 then return 0, 0, 0 end
local origin = active_buffer_path()
local total = 0
local edit_total, files, res_ops = 0, 0, 0
for _, item in ipairs(plan) do
if item.kind == "edit" then
pmacs.buffer.find_or_open(item.path)
total = total + apply_text_edits(item.edits)
edit_total = edit_total + apply_text_edits(item.edits)
files = files + 1
else
pmacs.buffer.apply_resource_op(item)
res_ops = res_ops + 1
end
-- Return the user to where they invoked rename from.
if origin then pmacs.buffer.find_or_open(origin) end
return total, #plan
end
-- Return the user to where they invoked from — best-effort, since
-- that path may have just been renamed or deleted.
if origin then pcall(pmacs.buffer.find_or_open, origin) end
return edit_total, files, res_ops
end
-- T M4.5 L3 — server→client `workspace/applyEdit` pump.
@ -426,7 +463,7 @@ local function handle_apply_edit_requests()
local applied, reason = false, nil
if edit then
local parsed = pmacs.lsp._parse_workspace_edit(edit)
local n, info = apply_workspace_edit(parsed.files)
local n, info = apply_workspace_edit(parsed.ops)
if n then applied = true else reason = info end
else
reason = "missing edit"
@ -641,26 +678,24 @@ function pmacs.lsp.rename()
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
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, info = apply_workspace_edit(fe)
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(info))
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"),
info, (info == 1 and "" or "s"))
if skipped > 0 then
files, (files == 1 and "" or "s"))
if res and res > 0 then
msg = msg .. string.format(
" (%d unsupported op%s skipped)",
skipped, (skipped == 1 and "" or "s"))
" (+%d file op%s)", res, (res == 1 and "" or "s"))
end
pmacs.editor.set_status(msg)
end)
@ -702,12 +737,14 @@ function pmacs.lsp.code_actions()
local first = acts[1]
local bits = {}
if first.has_edit then
local n, info = apply_workspace_edit(first.edit)
local n, files, res = apply_workspace_edit(first.edit)
if not n then
pmacs.editor.set_status("LSP: code action aborted: " .. tostring(info))
pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files))
return
end
table.insert(bits, string.format("%d edit(s) / %d file(s)", n, info))
local b = string.format("%d edit(s) / %d file(s)", n, files)
if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end
table.insert(bits, b)
end
if first.command then
local ok2, cerr = pcall(function()

View File

@ -438,8 +438,7 @@ fn main() {
{
"textDocument": { "uri": second, "version": 1 },
"edits": edit.clone()
},
{ "kind": "create", "uri": "file:///tmp/pmacs-fake-created.rs" }
}
]
})
} else {
@ -523,13 +522,41 @@ fn main() {
.and_then(|a| a.first())
.cloned()
.unwrap_or(serde_json::Value::Null);
let apply = serde_json::json!({
"jsonrpc": "2.0",
"id": 9100,
"method": "workspace/applyEdit",
"params": {
"label": "fake refactor",
"edit": {
// T M4.5 L4 `resourceops`: deliver an ordered
// documentChanges that creates a file, fills it
// (create-before-edit ordering), renames a
// sibling, and deletes another — paths derived
// from the request URI's directory so the test
// doesn't have to thread them through env.
let we = if mode == "resourceops" {
let s = target.as_str().unwrap_or("");
let base = match s.rfind('/') {
Some(i) => &s[..=i],
None => "",
};
let created = format!("{base}created.rs");
let b = format!("{base}b.rs");
let b2 = format!("{base}b2.rs");
let c = format!("{base}c.rs");
serde_json::json!({
"documentChanges": [
{ "kind": "create", "uri": created },
{
"textDocument": { "uri": created, "version": 1 },
"edits": [{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"newText": "NEW"
}]
},
{ "kind": "rename", "oldUri": b, "newUri": b2 },
{ "kind": "delete", "uri": c }
]
})
} else {
serde_json::json!({
"documentChanges": [{
"textDocument": { "uri": target, "version": 1 },
"edits": [{
@ -540,8 +567,13 @@ fn main() {
"newText": "ED2"
}]
}]
}
}
})
};
let apply = serde_json::json!({
"jsonrpc": "2.0",
"id": 9100,
"method": "workspace/applyEdit",
"params": { "label": "fake refactor", "edit": we }
});
write_frame(&mut stdout, &apply);
}

View File

@ -246,7 +246,7 @@ mod tests {
let a = &r.actions[0];
assert_eq!(a.kind.as_deref(), Some("quickfix"));
assert!(a.has_edit());
assert_eq!(a.edit.files[0].uri, "file:///a.rs");
assert_eq!(a.edit.files()[0].uri, "file:///a.rs");
assert!(a.command.is_none());
}

View File

@ -2399,6 +2399,105 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
)?;
}
{
// T M4.5 L4 — apply one LSP `WorkspaceEdit` resource op.
// Callers (the `apply_workspace_edit` Lua applier) resolve
// URIs to paths first, so this works in plain filesystem
// paths and also reconciles any open buffer: a renamed file's
// buffer is rebound to the new path; a deleted file's buffer
// is removed. `spec.kind` is "create" | "rename" | "delete".
let reg = registry.clone();
buffer.set(
"apply_resource_op",
lua.create_function(move |lua, spec: Table| -> mlua::Result<()> {
let io_err = |ctx: &str, e: std::io::Error| {
mlua::Error::external(std::io::Error::new(
e.kind(),
format!("apply_resource_op {ctx}: {e}"),
))
};
let kind: String = spec.get("kind")?;
match kind.as_str() {
"create" => {
let path: String = spec.get("path")?;
let pb = std::path::PathBuf::from(&path);
let overwrite: bool = spec.get("overwrite").unwrap_or(false);
let ignore_if_exists: bool = spec.get("ignore_if_exists").unwrap_or(false);
if pb.exists() && ignore_if_exists && !overwrite {
return Ok(());
}
if let Some(parent) = pb.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| io_err("create (parents)", e))?;
}
// Create, or truncate when overwrite is set /
// implied (no options ⇒ overwrite per spec).
std::fs::write(&pb, b"").map_err(|e| io_err("create", e))?;
}
"rename" => {
let old_p: String = spec.get("old_path")?;
let new_p: String = spec.get("new_path")?;
let from = std::path::PathBuf::from(&old_p);
let to = std::path::PathBuf::from(&new_p);
let overwrite: bool = spec.get("overwrite").unwrap_or(false);
let ignore_if_exists: bool = spec.get("ignore_if_exists").unwrap_or(false);
if to.exists() && ignore_if_exists && !overwrite {
return Ok(());
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| io_err("rename (parents)", e))?;
}
std::fs::rename(&from, &to).map_err(|e| io_err("rename", e))?;
let bid = reg.borrow().find_by_path(&from);
if let Some(id) = bid
&& let Some(core) = lua.app_data_ref::<SharedCore>()
{
core.borrow_mut().set_buffer_path(id, Some(to.clone()));
}
}
"delete" => {
let path: String = spec.get("path")?;
let pb = std::path::PathBuf::from(&path);
let recursive: bool = spec.get("recursive").unwrap_or(false);
let ignore_if_not_exists: bool =
spec.get("ignore_if_not_exists").unwrap_or(false);
match std::fs::symlink_metadata(&pb) {
Ok(md) => {
let r = if md.is_dir() {
if recursive {
std::fs::remove_dir_all(&pb)
} else {
std::fs::remove_dir(&pb)
}
} else {
std::fs::remove_file(&pb)
};
r.map_err(|e| io_err("delete", e))?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if !ignore_if_not_exists {
return Err(io_err("delete", e));
}
}
Err(e) => return Err(io_err("delete (stat)", e)),
}
let bid = reg.borrow().find_by_path(&pb);
if let Some(id) = bid {
remove_buffer_and_fire(lua, &reg, id)?;
}
}
other => {
return Err(mlua::Error::external(format!(
"apply_resource_op: unknown kind {other:?}"
)));
}
}
Ok(())
})?,
)?;
}
{
let reg = registry.clone();
buffer.set(
@ -7453,17 +7552,16 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
{
// Normalise an arbitrary LSP `WorkspaceEdit` JSON value (e.g.
// a server→client `workspace/applyEdit` param) into the same
// `{ files = { { uri, edits } }, unsupported = n }` shape the
// rename/code-action surfaces hand back — so the Lua applier
// has exactly one input format regardless of origin.
// `{ ops = { … } }` ordered-op shape the rename/code-action
// surfaces hand back — so the Lua applier has exactly one
// input format regardless of origin.
lsp_mod.set(
"_parse_workspace_edit",
lua.create_function(move |lua, edit: Value| {
let json = lua_to_json(edit)?;
let parsed = WorkspaceEditResponse::from_lsp_value(&json);
let out = lua.create_table_with_capacity(0, 2)?;
out.set("files", workspace_edit_to_lua(lua, &parsed)?)?;
out.set("unsupported", parsed.unsupported_ops)?;
let out = lua.create_table_with_capacity(0, 1)?;
out.set("ops", workspace_ops_to_lua(lua, &parsed)?)?;
Ok(out)
})?,
)?;
@ -8507,7 +8605,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::rename::{RenameKey, WorkspaceEditResponse, WorkspaceOp};
use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter};
use crate::symbol::{Symbol as LspSymbol, SymbolKey};
@ -9179,9 +9277,12 @@ 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() {
/// The edit ops only, as `{ { uri =, edits = { … } }, … }` — the
/// back-compat per-file view (`pmacs.rename.file_edits`).
fn file_edits_to_lua(lua: &Lua, r: &WorkspaceEditResponse) -> mlua::Result<Table> {
let files = r.files();
let out = lua.create_table_with_capacity(files.len(), 0)?;
for (i, f) in 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)?;
@ -9189,16 +9290,73 @@ fn workspace_edit_to_lua(lua: &Lua, r: &WorkspaceEditResponse) -> mlua::Result<T
edits.set(j + 1, text_edit_to_lua(lua, e)?)?;
}
entry.set("edits", edits)?;
files.set(i + 1, entry)?;
out.set(i + 1, entry)?;
}
Ok(files)
Ok(out)
}
/// 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.
/// The full `WorkspaceEdit` as an ordered op list. Each element is a
/// table tagged by `op`: `"edit"` (`uri`, `edits`), `"create"`
/// (`uri`, `overwrite`, `ignore_if_exists`), `"rename"` (`old_uri`,
/// `new_uri`, `overwrite`, `ignore_if_exists`), or `"delete"` (`uri`,
/// `recursive`, `ignore_if_not_exists`). Order is the server's.
fn workspace_ops_to_lua(lua: &Lua, r: &WorkspaceEditResponse) -> mlua::Result<Table> {
let out = lua.create_table_with_capacity(r.ops.len(), 0)?;
for (i, op) in r.ops.iter().enumerate() {
let t = lua.create_table()?;
match op {
WorkspaceOp::Edit(f) => {
t.set("op", "edit")?;
t.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)?)?;
}
t.set("edits", edits)?;
}
WorkspaceOp::Create {
uri,
overwrite,
ignore_if_exists,
} => {
t.set("op", "create")?;
t.set("uri", uri.as_str())?;
t.set("overwrite", *overwrite)?;
t.set("ignore_if_exists", *ignore_if_exists)?;
}
WorkspaceOp::Rename {
old_uri,
new_uri,
overwrite,
ignore_if_exists,
} => {
t.set("op", "rename")?;
t.set("old_uri", old_uri.as_str())?;
t.set("new_uri", new_uri.as_str())?;
t.set("overwrite", *overwrite)?;
t.set("ignore_if_exists", *ignore_if_exists)?;
}
WorkspaceOp::Delete {
uri,
recursive,
ignore_if_not_exists,
} => {
t.set("op", "delete")?;
t.set("uri", uri.as_str())?;
t.set("recursive", *recursive)?;
t.set("ignore_if_not_exists", *ignore_if_not_exists)?;
}
}
out.set(i + 1, t)?;
}
Ok(out)
}
/// Install `pmacs.rename.*`. `ops(sid, uri)` returns the parsed
/// `WorkspaceEdit` as an ordered op list (T M4.5 L4 — edits and
/// resource ops interleaved exactly as the server sent them);
/// `file_edits(sid, uri)` is the edit-only back-compat view (`{ {
/// uri =, edits = { … } }, … }`); `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()?;
@ -9206,13 +9364,13 @@ pub fn install_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()>
{
let mgr = manager.clone();
m.set(
"file_edits",
"ops",
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)?))
Ok(Value::Table(workspace_ops_to_lua(lua, r)?))
} else {
Ok(Value::Table(lua.create_table_with_capacity(0, 0)?))
}
@ -9223,12 +9381,16 @@ pub fn install_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()>
{
let mgr = manager.clone();
m.set(
"unsupported",
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
"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);
Ok(guard.get(&key).map_or(0, |r| r.unsupported_ops))
if let Some(r) = guard.get(&key) {
Ok(Value::Table(file_edits_to_lua(lua, r)?))
} else {
Ok(Value::Table(lua.create_table_with_capacity(0, 0)?))
}
})?,
)?;
}
@ -9257,8 +9419,9 @@ fn code_action_item_to_lua(lua: &Lua, a: &CodeActionItem) -> mlua::Result<Table>
t.set("kind", k)?;
}
t.set("has_edit", a.has_edit())?;
// Always present (possibly empty) so Lua can `#item.edit`.
t.set("edit", workspace_edit_to_lua(lua, &a.edit)?)?;
// Always present (possibly empty) so Lua can `#item.edit`. The
// ordered-op shape, identical to `pmacs.rename.ops`.
t.set("edit", workspace_ops_to_lua(lua, &a.edit)?)?;
if let Some(c) = a.command.as_ref() {
let ct = lua.create_table_with_capacity(0, 3)?;
ct.set("command", c.command.as_str())?;

View File

@ -1,23 +1,29 @@
// rename.rs --- T M4.5 L2 LSP-backed rename / WorkspaceEdit state.
// rename.rs --- T M4.5 LSP-backed rename / WorkspaceEdit state.
//! `textDocument/rename` response state.
//! `textDocument/rename` (and any other) `WorkspaceEdit` state.
//!
//! A rename answer is an LSP [`WorkspaceEdit`], which may touch many
//! files. This module parses both edit carriers —
//! A `WorkspaceEdit` may touch many files and, via `documentChanges`,
//! interleave text edits with filesystem *resource operations*
//! (create / rename / delete file). This module parses both carriers —
//!
//! * `changes`: `{ uri: TextEdit[] }`
//! * `documentChanges`: `(TextDocumentEdit | resource-op)[]`
//! * `documentChanges`: `(TextDocumentEdit | CreateFile |
//! RenameFile | DeleteFile)[]`
//!
//! — 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.
//! — into a single **ordered** [`WorkspaceOp`] list
//! ([`WorkspaceEditResponse::ops`]). Order is preserved exactly as the
//! server sent it, because the spec requires sequential application
//! (e.g. a `CreateFile` must precede the `TextDocumentEdit` that fills
//! the new file). The `changes` map, which carries no resource ops and
//! no inherent order, is emitted as URI-sorted edit ops for
//! determinism.
//!
//! 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.
//! The [`crate::formatting::TextEdit`] shape is reused verbatim (same
//! zero-based, UTF-16-column coordinates). As with
//! [`crate::formatting`], nothing here mutates the editor or the disk:
//! Lua reads the ordered ops and drives `pmacs.buffer.*` /
//! `pmacs.editor.*` (text edits) and `pmacs.buffer.apply_resource_op`
//! (filesystem ops) so the application strategy stays configurable.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@ -36,15 +42,47 @@ pub struct FileEdits {
pub edits: Vec<TextEdit>,
}
/// A parsed `WorkspaceEdit`: per-file edit lists plus a count of
/// resource operations we deliberately did not apply (L4).
/// One entry of a `WorkspaceEdit`, in server-sent order.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WorkspaceOp {
/// Text edits for a single document.
Edit(FileEdits),
/// `CreateFile`. `overwrite` wins over `ignore_if_exists`.
Create {
/// URI to create.
uri: String,
/// Truncate if it already exists.
overwrite: bool,
/// No-op if it already exists (loses to `overwrite`).
ignore_if_exists: bool,
},
/// `RenameFile`.
Rename {
/// Existing URI.
old_uri: String,
/// Destination URI.
new_uri: String,
/// Overwrite the destination if it exists.
overwrite: bool,
/// No-op if the destination exists (loses to `overwrite`).
ignore_if_exists: bool,
},
/// `DeleteFile`.
Delete {
/// URI to delete.
uri: String,
/// Recurse into a directory.
recursive: bool,
/// Not an error if it is already gone.
ignore_if_not_exists: bool,
},
}
/// A parsed `WorkspaceEdit`: an ordered list of operations.
#[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,
/// Operations in server-sent order.
pub ops: Vec<WorkspaceOp>,
}
impl WorkspaceEditResponse {
@ -52,71 +90,113 @@ impl WorkspaceEditResponse {
///
/// 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).
/// result yields an empty response.
#[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);
return Self {
ops: dc.iter().filter_map(parse_document_change).collect(),
};
}
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 {
let mut edits: Vec<FileEdits> = changes
.iter()
.map(|(uri, e)| FileEdits {
uri: uri.clone(),
edits: parse_edit_array(edits),
});
}
// Object iteration order is unspecified; sort by URI so the
edits: parse_edit_array(e),
})
.collect();
// `changes` has no inherent order; sort by URI so the
// applier (and tests) see a deterministic sequence.
files.sort_by(|a, b| a.uri.cmp(&b.uri));
edits.sort_by(|a, b| a.uri.cmp(&b.uri));
return Self {
files,
unsupported_ops: 0,
ops: edits.into_iter().map(WorkspaceOp::Edit).collect(),
};
}
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.
/// True iff there is nothing to do — no ops, or only empty text
/// edits. Any resource op makes this `false` (the edit is
/// meaningful even with zero text changes).
#[must_use]
pub fn is_empty(&self) -> bool {
self.files.iter().all(|f| f.edits.is_empty()) && self.unsupported_ops == 0
self.ops
.iter()
.all(|op| matches!(op, WorkspaceOp::Edit(f) if f.edits.is_empty()))
}
/// Total edits across every file.
/// Total text edits across every edit op.
#[must_use]
pub fn edit_count(&self) -> usize {
self.files.iter().map(|f| f.edits.len()).sum()
self.ops
.iter()
.map(|op| match op {
WorkspaceOp::Edit(f) => f.edits.len(),
_ => 0,
})
.sum()
}
/// Number of filesystem resource ops (create/rename/delete).
#[must_use]
pub fn resource_op_count(&self) -> usize {
self.ops
.iter()
.filter(|op| !matches!(op, WorkspaceOp::Edit(_)))
.count()
}
/// The edit ops only, in order — the back-compat view for callers
/// that just want per-file text edits.
#[must_use]
pub fn files(&self) -> Vec<&FileEdits> {
self.ops
.iter()
.filter_map(|op| match op {
WorkspaceOp::Edit(f) => Some(f),
_ => None,
})
.collect()
}
}
fn parse_document_change(entry: &Value) -> Option<WorkspaceOp> {
// A resource op is tagged with a string `kind`; a
// TextDocumentEdit has `textDocument` + `edits` and no `kind`.
match entry.get("kind").and_then(Value::as_str) {
Some("create") => Some(WorkspaceOp::Create {
uri: entry.get("uri")?.as_str()?.to_owned(),
overwrite: opt_bool(entry, "overwrite"),
ignore_if_exists: opt_bool(entry, "ignoreIfExists"),
}),
Some("rename") => Some(WorkspaceOp::Rename {
old_uri: entry.get("oldUri")?.as_str()?.to_owned(),
new_uri: entry.get("newUri")?.as_str()?.to_owned(),
overwrite: opt_bool(entry, "overwrite"),
ignore_if_exists: opt_bool(entry, "ignoreIfExists"),
}),
Some("delete") => Some(WorkspaceOp::Delete {
uri: entry.get("uri")?.as_str()?.to_owned(),
recursive: opt_bool(entry, "recursive"),
ignore_if_not_exists: opt_bool(entry, "ignoreIfNotExists"),
}),
// Unknown future resource kind — skip rather than misapply.
Some(_) => None,
None => {
let uri = entry.get("textDocument")?.get("uri")?.as_str()?.to_owned();
let edits = entry.get("edits").map(parse_edit_array).unwrap_or_default();
Some(WorkspaceOp::Edit(FileEdits { uri, edits }))
}
}
}
fn opt_bool(entry: &Value, key: &str) -> bool {
entry
.get("options")
.and_then(|o| o.get(key))
.and_then(Value::as_bool)
.unwrap_or(false)
}
/// Parse a `(TextEdit | AnnotatedTextEdit)[]` value into edits,
@ -234,47 +314,89 @@ mod tests {
}
});
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");
let files = r.files();
assert_eq!(files.len(), 2);
assert_eq!(files[0].uri, "file:///a.rs");
assert_eq!(files[0].edits.len(), 2);
assert_eq!(files[1].uri, "file:///b.rs");
assert_eq!(r.edit_count(), 3);
assert_eq!(r.unsupported_ops, 0);
assert_eq!(r.resource_op_count(), 0);
}
#[test]
fn parses_document_changes_and_prefers_it_over_changes() {
fn document_changes_preserves_order_with_resource_ops() {
let v = json!({
"changes": { "file:///ignored.rs": [one_edit("NO")] },
"documentChanges": [
{
"textDocument": { "uri": "file:///a.rs", "version": 1 },
"edits": [one_edit("A")]
}
{ "kind": "create", "uri": "file:///new.rs",
"options": { "ignoreIfExists": true } },
{ "textDocument": { "uri": "file:///new.rs", "version": 1 },
"edits": [one_edit("A")] },
{ "kind": "rename", "oldUri": "file:///a.rs",
"newUri": "file:///c.rs", "options": { "overwrite": true } },
{ "kind": "delete", "uri": "file:///d.rs",
"options": { "recursive": true, "ignoreIfNotExists": true } }
]
});
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");
assert_eq!(r.ops.len(), 4);
assert_eq!(r.resource_op_count(), 3);
assert_eq!(r.edit_count(), 1);
match &r.ops[0] {
WorkspaceOp::Create {
uri,
overwrite,
ignore_if_exists,
} => {
assert_eq!(uri, "file:///new.rs");
assert!(!overwrite);
assert!(ignore_if_exists);
}
other => panic!("expected Create, got {other:?}"),
}
match &r.ops[1] {
WorkspaceOp::Edit(f) => assert_eq!(f.uri, "file:///new.rs"),
other => panic!("expected Edit, got {other:?}"),
}
match &r.ops[2] {
WorkspaceOp::Rename {
old_uri,
new_uri,
overwrite,
..
} => {
assert_eq!(old_uri, "file:///a.rs");
assert_eq!(new_uri, "file:///c.rs");
assert!(overwrite);
}
other => panic!("expected Rename, got {other:?}"),
}
match &r.ops[3] {
WorkspaceOp::Delete {
uri,
recursive,
ignore_if_not_exists,
} => {
assert_eq!(uri, "file:///d.rs");
assert!(recursive);
assert!(ignore_if_not_exists);
}
other => panic!("expected Delete, got {other:?}"),
}
}
#[test]
fn resource_ops_are_counted_not_applied() {
fn unknown_resource_kind_is_skipped() {
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" }
{ "kind": "teleport", "uri": "file:///x" },
{ "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.unsupported_ops, 2);
assert!(!r.is_empty());
assert_eq!(r.ops.len(), 1);
assert_eq!(r.edit_count(), 1);
}
#[test]
@ -284,6 +406,17 @@ mod tests {
assert_eq!(r.edit_count(), 0);
}
#[test]
fn resource_only_edit_is_not_empty() {
let v = json!({ "documentChanges": [
{ "kind": "delete", "uri": "file:///gone.rs" }
]});
let r = WorkspaceEditResponse::from_lsp_value(&v);
assert!(!r.is_empty());
assert_eq!(r.edit_count(), 0);
assert_eq!(r.resource_op_count(), 1);
}
#[test]
fn annotated_text_edit_parses_like_plain() {
let v = json!({
@ -300,8 +433,9 @@ mod tests {
}]
});
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);
let f = r.files();
assert_eq!(f[0].edits[0].new_text, "Q");
assert_eq!(f[0].edits[0].start_line, 2);
}
#[test]
@ -311,7 +445,7 @@ mod tests {
s.set(
key.clone(),
WorkspaceEditResponse {
files: vec![FileEdits {
ops: vec![WorkspaceOp::Edit(FileEdits {
uri: "file:///a".into(),
edits: vec![TextEdit {
start_line: 0,
@ -320,11 +454,10 @@ mod tests {
end_col: 1,
new_text: "x".into(),
}],
}],
unsupported_ops: 0,
})],
},
);
assert_eq!(s.get(&key).unwrap().files.len(), 1);
assert_eq!(s.get(&key).unwrap().files().len(), 1);
s.clear(&key);
assert!(s.get(&key).is_none());
}

View File

@ -3646,6 +3646,106 @@ fn m4_14_code_action_command_drives_apply_edit() {
assert_eq!(active.as_deref(), Some(a_disp.as_str()));
}
/// T M4.5 L4 — ordered `WorkspaceEdit` resource operations. The
/// `resourceops` fake answers an executeCommand-driven
/// `workspace/applyEdit` with `documentChanges` that **create** a
/// file, **edit** that just-created file (proving create-before-edit
/// ordering is honoured), **rename** a sibling, and **delete**
/// another. The applier must perform all four against the real
/// filesystem and reconcile the buffer registry.
#[test]
fn m4_15_workspace_edit_resource_ops_apply_in_order() {
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");
let c_path = dir.path().join("c.rs");
std::fs::write(&a_path, b"abcfooxyz\n___zzz\n").expect("write a");
std::fs::write(&b_path, b"mod b;\n").expect("write b");
std::fs::write(&c_path, b"gone\n").expect("write c");
let a_disp = a_path.display().to_string();
let created = dir.path().join("created.rs");
let b2 = dir.path().join("b2.rs");
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 = 'resourceops' }},
}}"
))
.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.code_actions()")
.exec()
.expect("invoke code actions");
// Completion signal: the created file exists on disk. Tick the
// full frame order (processes → lsp → async) so the
// executeCommand round-trip, the server-initiated applyEdit
// request, and the Lua applyEdit pump all run.
let created_disp = created.display().to_string();
let deadline = Instant::now() + Duration::from_secs(5);
while !created.exists() {
assert!(
Instant::now() < deadline,
"resource ops never created the new file"
);
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(10));
}
// Rename moved b.rs -> b2.rs; delete removed c.rs.
assert!(b2.exists(), "RenameFile should have produced b2.rs");
assert!(!b_path.exists(), "RenameFile should have removed b.rs");
assert!(!c_path.exists(), "DeleteFile should have removed c.rs");
// The edit op ran *after* the create op, against the new file's
// buffer (create-before-edit ordering preserved).
let new_text: String = state
.lua_host
.lua()
.load(format!(
"pmacs.buffer.find_or_open('{created_disp}') \
local b = pmacs.window.buffer() return b:slice(0, b:len())"
))
.eval()
.unwrap();
assert_eq!(
new_text, "NEW",
"created file should have been filled by the edit op"
);
}
/// 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