diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 468c05d..8bdab05 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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 - pmacs.buffer.find_or_open(item.path) - total = total + apply_text_edits(item.edits) + if item.kind == "edit" then + pmacs.buffer.find_or_open(item.path) + 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 end - -- Return the user to where they invoked rename from. - if origin then pmacs.buffer.find_or_open(origin) end - return total, #plan + -- 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() diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index d2c4d33..f877721 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -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,25 +522,58 @@ fn main() { .and_then(|a| a.first()) .cloned() .unwrap_or(serde_json::Value::Null); + // 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": [{ + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 3 } + }, + "newText": "ED2" + }] + }] + }) + }; let apply = serde_json::json!({ "jsonrpc": "2.0", "id": 9100, "method": "workspace/applyEdit", - "params": { - "label": "fake refactor", - "edit": { - "documentChanges": [{ - "textDocument": { "uri": target, "version": 1 }, - "edits": [{ - "range": { - "start": { "line": 1, "character": 0 }, - "end": { "line": 1, "character": 3 } - }, - "newText": "ED2" - }] - }] - } - } + "params": { "label": "fake refactor", "edit": we } }); write_frame(&mut stdout, &apply); } diff --git a/src/code_action.rs b/src/code_action.rs index c70ea4a..5c40b4e 100644 --- a/src/code_action.rs +++ b/src/code_action.rs @@ -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()); } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index b58c508..cf6541b 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -2399,6 +2399,105 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result 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::() + { + 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 { - 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
{ + 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 mlua::Result
{ + 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
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())?; diff --git a/src/rename.rs b/src/rename.rs index 3b103d4..43c29d7 100644 --- a/src/rename.rs +++ b/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, } -/// 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, - /// 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, } 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 = 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 { + // 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()); } diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index d3cbe50..5829928 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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