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:
parent
21ff334bcc
commit
a865dc7a76
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ®, 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())?;
|
||||
|
|
|
|||
323
src/rename.rs
323
src/rename.rs
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue