diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index d02ca77..2ec0e26 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -4,14 +4,15 @@ -- * Declarative server config (`pmacs.lsp.config[language]`). -- * Auto-attach + did_open / did_change / did_close on buffer events. -- * `pmacs.lsp.go_to_definition` / `pmacs.lsp.format_buffer` / --- `pmacs.lsp.hover_at_cursor` / `pmacs.lsp.signature_help_at_cursor`, --- bound to default chords below. +-- `pmacs.lsp.hover_at_cursor` / `pmacs.lsp.signature_help_at_cursor` +-- / `pmacs.lsp.rename`, bound to default chords below. -- --- v0.1 scope: one server per language across all buffers; same-file --- definition jumps only; synchronous request → poll → react cycle --- (sub-second for hot servers). Cross-file navigation, async-await --- coroutines, rename, code actions, inlay hints, semantic tokens, and --- file-watch capability registration are all v0.2 work. +-- Scope: one server per language across all buffers; async-await +-- request/react (the editor never blocks). Cross-file go-to-definition +-- and a multi-file rename / WorkspaceEdit applier have landed (L1/L2). +-- Code actions, inlay hints, semantic tokens, resource-op edits +-- (create/rename/delete file), and file-watch capability registration +-- are later layers. pmacs.lsp = pmacs.lsp or {} pmacs.lsp.config = pmacs.lsp.config or {} @@ -263,6 +264,7 @@ pmacs.lsp.request_implementation = wrap_request(pmacs.lsp._request_implementatio pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_symbol_raw) pmacs.lsp.request_workspace_symbol = wrap_request(pmacs.lsp._request_workspace_symbol_raw) pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_highlight_raw) +pmacs.lsp.request_rename = wrap_request(pmacs.lsp._request_rename_raw) -- Render an `:await()` failure into a modeline-friendly reason. -- `Handle:await()` raises `{ tag = "cancelled", ... }` when the @@ -344,6 +346,46 @@ local function apply_text_edits(edits) return #resolved end +-- T M4.5 L2 — apply a parsed LSP `WorkspaceEdit` (`pmacs.rename`'s +-- per-file shape: `{ { uri = , edits = { … } }, … }`) across however +-- many files it touches. +-- +-- Atomicity: a true cross-buffer transaction is out of scope here, so +-- the applier instead refuses to mutate *anything* unless every URI +-- with edits resolves to a real file path first (`path_for_uri`). A +-- rename that names an `untitled:`/non-file document aborts cleanly +-- with the origin buffer untouched, rather than half-applying. +-- +-- Per file the edits are applied through `apply_text_edits`, which +-- resolves offsets against that buffer's *original* text and applies +-- in reverse-start order — correct because each file's edits are +-- independent and `find_or_open` makes the target the active buffer +-- before its batch runs. The buffer the user invoked from is restored +-- last. Returns `total_edits, file_count` on success, or +-- `nil, message` if the preflight rejected the edit. +local function apply_workspace_edit(file_edits) + local plan = {} + for _, fe in ipairs(file_edits or {}) do + if fe.edits and #fe.edits > 0 then + local path = pmacs.lsp.path_for_uri(fe.uri) + if not path then + return nil, "cannot resolve " .. tostring(fe.uri) + end + table.insert(plan, { path = path, edits = fe.edits }) + end + end + if #plan == 0 then return 0, 0 end + local origin = active_buffer_path() + local total = 0 + for _, item in ipairs(plan) do + pmacs.buffer.find_or_open(item.path) + total = total + apply_text_edits(item.edits) + end + -- Return the user to where they invoked rename from. + if origin then pmacs.buffer.find_or_open(origin) end + return total, #plan +end + -- Commands ---------------------------------------------------------------- -- Each command captures the cursor/target at invocation time, then @@ -503,6 +545,66 @@ function pmacs.lsp.format_buffer() end) end +-- T M4.5 L2 — rename the symbol under the cursor. Prompts for the new +-- name in the minibuffer; on accept, sends `textDocument/rename`, +-- awaits the `WorkspaceEdit`, and drives it through the multi-file +-- applier. The position is captured *before* the prompt opens so the +-- request still targets the original symbol even though the minibuffer +-- session moved focus. +function pmacs.lsp.rename() + local rec = attached_for_active() + if not rec then + pmacs.editor.set_status("LSP: no server for active buffer") + return + end + local line = pmacs.editor.cursor_line() + local col = pmacs.editor.cursor_col() + pmacs.minibuffer.read { + prompt = "Rename symbol to: ", + on_cancel = function() + pmacs.editor.set_status("LSP: rename cancelled") + end, + on_accept = function(new_name) + if not new_name or new_name == "" then + pmacs.editor.set_status("LSP: rename needs a new name") + return + end + pmacs.rename.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local fe = pmacs.rename.file_edits(rec.server, rec.uri) + local skipped = pmacs.rename.unsupported(rec.server, rec.uri) + if (not fe or #fe == 0) and skipped == 0 then + pmacs.editor.set_status("LSP: rename produced no edits") + return + end + local n, info = apply_workspace_edit(fe) + if not n then + -- Preflight rejected it; nothing was mutated. + pmacs.editor.set_status("LSP: rename aborted: " .. tostring(info)) + return + end + local msg = string.format( + "LSP: renamed — %d edit%s across %d file%s", + n, (n == 1 and "" or "s"), + info, (info == 1 and "" or "s")) + if skipped > 0 then + msg = msg .. string.format( + " (%d unsupported op%s skipped)", + skipped, (skipped == 1 and "" or "s")) + end + pmacs.editor.set_status(msg) + end) + end, + } +end + function pmacs.lsp.hover_at_cursor() local rec = attached_for_active() if not rec then @@ -598,6 +700,12 @@ pmacs.command.define { fn = pmacs.lsp.document_symbols, } +pmacs.command.define { + name = "lsp.rename", + description = "Rename the symbol under the cursor across the workspace (LSP).", + fn = pmacs.lsp.rename, +} + -- T M4.5 L1 — unwind the cross-file jump ring. Pairs with the -- `pmacs.editor.push_jump()` every navigation action records before -- it moves the cursor. @@ -619,6 +727,7 @@ pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-d pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" } pmacs.keymap.bind { scope = "global", sequence = "M-,", command = "lsp.jump-back" } pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" } +pmacs.keymap.bind { scope = "global", sequence = "C-c r", command = "lsp.rename" } pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" } pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" } pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" } diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 89a05fa..c2c50d6 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -400,6 +400,60 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("textDocument/rename", Some(idv)) => { + // T M4.5 L2: reply with a `WorkspaceEdit`. The edit + // replaces the 3-char span at line 0, cols 3..6 with + // the requested `newName` (so the test can assert the + // buffer text changed). In `rename` mode a *second* + // file URI is taken from `PMACS_FAKE_LSP_RENAME_URI` + // and given the same edit, plus a `create` resource + // op — exercising the cross-file applier and the + // unsupported-op count. Otherwise the edit is + // single-file (the request's own document). + let uri = params + .get("textDocument") + .and_then(|t| t.get("uri")) + .cloned() + .unwrap_or(serde_json::Value::Null); + let new_name = params + .get("newName") + .and_then(serde_json::Value::as_str) + .unwrap_or("renamed") + .to_owned(); + let edit = serde_json::json!([{ + "range": { + "start": { "line": 0, "character": 3 }, + "end": { "line": 0, "character": 6 } + }, + "newText": new_name + }]); + let workspace_edit = if mode == "rename" { + let second = std::env::var("PMACS_FAKE_LSP_RENAME_URI").unwrap_or_default(); + serde_json::json!({ + "documentChanges": [ + { + "textDocument": { "uri": uri, "version": 1 }, + "edits": edit.clone() + }, + { + "textDocument": { "uri": second, "version": 1 }, + "edits": edit.clone() + }, + { "kind": "create", "uri": "file:///tmp/pmacs-fake-created.rs" } + ] + }) + } else { + let mut changes = serde_json::Map::new(); + changes.insert(uri.as_str().unwrap_or("").to_owned(), edit); + serde_json::json!({ "changes": changes }) + }; + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": workspace_edit + }); + write_frame(&mut stdout, &resp); + } // T M4.5 symbols/highlight. documentSymbol returns the // *hierarchical* DocumentSymbol shape (exercises tree // flatten + depth + parent); workspace/symbol the flat diff --git a/src/lib.rs b/src/lib.rs index 2575477..0b76ca4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,7 @@ pub mod process; pub mod project; pub mod project_index; pub mod protocol; +pub mod rename; pub mod rope; // T M11.5 — the headless semantic consumer composes BufferMirror + // optimistic (both `crdt`-gated) and is only meaningful on a diff --git a/src/lsp.rs b/src/lsp.rs index 9d51b5f..b8b0d46 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -756,6 +756,9 @@ pub struct LspManager { /// T M4.12 formatting store. Populated when a /// `textDocument/formatting` response lands. formatting_store: crate::formatting::SharedFormattingStore, + /// T M4.5 L2 rename store. Populated when a `textDocument/rename` + /// response (a `WorkspaceEdit`) lands, keyed by the origin uri. + rename_store: crate::rename::SharedRenameStore, /// Per-server request id → response routing target. /// `request_completion` etc. record an entry here; `handle_response` /// consumes it to absorb the response into the correct store. @@ -813,6 +816,9 @@ enum ResponseRoute { /// Absorb response into [`crate::formatting::FormattingStore`] at /// `(server, uri)`. Formatting { uri: String }, + /// Absorb a `textDocument/rename` `WorkspaceEdit` into + /// [`crate::rename::RenameStore`] at `(server, origin uri)`. + Rename { uri: String }, /// Absorb a Location-shaped nav response (references / declaration /// / typeDefinition / implementation) into /// [`crate::locations::LocationsStore`] at `(server, uri, kind)`. @@ -854,6 +860,7 @@ impl ResponseRoute { | ResponseRoute::Signature { uri } | ResponseRoute::Definition { uri } | ResponseRoute::Formatting { uri } + | ResponseRoute::Rename { uri } | ResponseRoute::Locations { uri, .. } | ResponseRoute::DocumentSymbol { uri } | ResponseRoute::DocumentHighlight { uri } => uri, @@ -935,6 +942,7 @@ impl LspManager { symbol_store: crate::symbol::make_shared_store(), document_highlight_store: crate::document_highlight::make_shared_store(), formatting_store: crate::formatting::make_shared_store(), + rename_store: crate::rename::make_shared_store(), pending_routes: HashMap::new(), status_tracker: crate::lsp_status::LspStatusTracker::new(), project_servers: HashMap::new(), @@ -1001,6 +1009,12 @@ impl LspManager { self.formatting_store.clone() } + /// Shared rename / `WorkspaceEdit` store (T M4.5 L2). + #[must_use] + pub fn rename_store(&self) -> crate::rename::SharedRenameStore { + self.rename_store.clone() + } + /// T M4.8: per-server status snapshot, derived from the LSP event /// stream. The modeline reads its label from this. #[must_use] @@ -1720,6 +1734,32 @@ impl LspManager { Ok(job_id) } + /// Send `textDocument/rename` for the symbol at `(line, col)` in + /// `uri`, requesting `new_name`. The response is a `WorkspaceEdit` + /// (possibly multi-file); it is absorbed into the rename store + /// keyed by the *origin* `uri`. Returns the async-runtime + /// [`JobId`] the response will settle. + pub fn request_rename( + &mut self, + sid: LspServerId, + uri: impl Into, + line: u32, + col: u32, + new_name: impl Into, + ) -> Result { + let uri = uri.into(); + let params = json!({ + "textDocument": { "uri": uri.clone() }, + "position": { "line": line, "character": col }, + "newName": new_name.into(), + }); + let req_id = self.send_request(sid, "textDocument/rename", params)?; + let job_id = self.register_awaiter(sid, req_id, "textDocument/rename", &uri); + self.pending_routes + .insert((sid, req_id), ResponseRoute::Rename { uri }); + Ok(job_id) + } + /// Reply to a server-initiated request. pub fn send_response( &mut self, @@ -2297,6 +2337,15 @@ impl LspManager { .expect("formatting store mutex poisoned"); guard.set(key, resp); } + ResponseRoute::Rename { uri } => { + let resp = crate::rename::WorkspaceEditResponse::from_lsp_value(result); + let key = crate::rename::RenameKey::new(server_key, uri.clone()); + let mut guard = self + .rename_store + .lock() + .expect("rename store mutex poisoned"); + guard.set(key, resp); + } } } @@ -2715,6 +2764,11 @@ fn default_capabilities() -> Value { "signatureHelp": { "dynamicRegistration": false }, "definition": { "dynamicRegistration": false, "linkSupport": true }, "formatting": { "dynamicRegistration": false }, + // T M4.5 L2: client-side rename. `prepareSupport: false` + // — pmacs sends `textDocument/rename` directly without a + // `prepareRename` round-trip (L2 scope); the symbol range + // comes from the cursor position. + "rename": { "dynamicRegistration": false, "prepareSupport": false }, "publishDiagnostics": { "relatedInformation": true }, }, }) diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 4a8a1ae..7b8b961 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7380,6 +7380,29 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + { + let m = manager.clone(); + lsp_mod.set( + "_request_rename_raw", + lua.create_function( + move |_, + (id, uri, line, col, new_name): ( + LspServerIdLua, + String, + u32, + u32, + String, + )| { + let job_id = m + .borrow_mut() + .request_rename(id.0, uri, line, col, new_name) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -7615,6 +7638,7 @@ pub fn make_lsp_manager( install_symbol(lua, &manager)?; install_document_highlight(lua, &manager)?; install_formatting(lua, &manager)?; + install_rename(lua, &manager)?; Ok(manager) } @@ -8415,6 +8439,7 @@ use crate::document_highlight::{DocumentHighlightKey, Highlight}; use crate::formatting::{FormattingKey, FormattingResponse, TextEdit}; use crate::hover::{Hover, HoverKey}; use crate::locations::{LocationKind, LocationsKey}; +use crate::rename::{RenameKey, WorkspaceEditResponse}; use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter}; use crate::symbol::{Symbol as LspSymbol, SymbolKey}; @@ -9086,6 +9111,77 @@ pub fn install_formatting(lua: &Lua, manager: &SharedLspManager) -> mlua::Result Ok(()) } +fn workspace_edit_to_lua(lua: &Lua, r: &WorkspaceEditResponse) -> mlua::Result { + let files = lua.create_table_with_capacity(r.files.len(), 0)?; + for (i, f) in r.files.iter().enumerate() { + let entry = lua.create_table_with_capacity(0, 2)?; + entry.set("uri", f.uri.as_str())?; + let edits = lua.create_table_with_capacity(f.edits.len(), 0)?; + for (j, e) in f.edits.iter().enumerate() { + edits.set(j + 1, text_edit_to_lua(lua, e)?)?; + } + entry.set("edits", edits)?; + files.set(i + 1, entry)?; + } + Ok(files) +} + +/// Install `pmacs.rename.*` (T M4.5 L2). `file_edits(sid, uri)` +/// returns the parsed `WorkspaceEdit` as `{ { uri = , edits = { … } }, +/// … }` (per-file, deterministic order); `unsupported(sid, uri)` is +/// the count of `create`/`rename`/`delete` file ops the L2 applier +/// skips; `clear(sid, uri)` drops the entry. +pub fn install_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let m = lua.create_table()?; + + { + let mgr = manager.clone(); + m.set( + "file_edits", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().rename_store(); + let guard = store_handle.lock().expect("rename store mutex poisoned"); + let key = RenameKey::new(id.0.raw().to_string(), uri); + if let Some(r) = guard.get(&key) { + Ok(Value::Table(workspace_edit_to_lua(lua, r)?)) + } else { + Ok(Value::Table(lua.create_table_with_capacity(0, 0)?)) + } + })?, + )?; + } + + { + let mgr = manager.clone(); + m.set( + "unsupported", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().rename_store(); + let guard = store_handle.lock().expect("rename store mutex poisoned"); + let key = RenameKey::new(id.0.raw().to_string(), uri); + Ok(guard.get(&key).map_or(0, |r| r.unsupported_ops)) + })?, + )?; + } + + { + let mgr = manager.clone(); + m.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().rename_store(); + let mut guard = store_handle.lock().expect("rename store mutex poisoned"); + guard.clear(&RenameKey::new(id.0.raw().to_string(), uri)); + Ok(()) + })?, + )?; + } + + pmacs.set("rename", m)?; + Ok(()) +} + // --------------------------------------------------------------------------- // pmacs.project: project model (T M4.9) // --------------------------------------------------------------------------- diff --git a/src/rename.rs b/src/rename.rs new file mode 100644 index 0000000..3b103d4 --- /dev/null +++ b/src/rename.rs @@ -0,0 +1,331 @@ +// rename.rs --- T M4.5 L2 LSP-backed rename / WorkspaceEdit state. + +//! `textDocument/rename` response state. +//! +//! A rename answer is an LSP [`WorkspaceEdit`], which may touch many +//! files. This module parses both edit carriers — +//! +//! * `changes`: `{ uri: TextEdit[] }` +//! * `documentChanges`: `(TextDocumentEdit | resource-op)[]` +//! +//! — into a flat, per-file edit list ([`WorkspaceEditResponse`]). The +//! [`crate::formatting::TextEdit`] shape is reused verbatim (same +//! zero-based, UTF-16-column coordinates). Resource operations +//! (`create` / `rename` / `delete` file) are L4 work; they are skipped +//! here and counted in [`WorkspaceEditResponse::unsupported_ops`] so +//! the Lua surface can warn rather than silently drop a partial rename. +//! +//! Like [`crate::formatting`], there is no Rust-side editor mutation: +//! Lua reads the per-file lists and drives `pmacs.buffer.*` / +//! `pmacs.editor.*` so the application strategy stays configurable. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +use crate::formatting::TextEdit; + +/// Edits the server wants applied to one document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileEdits { + /// Target document URI. + pub uri: String, + /// Edits for this document, in server order. Callers apply in + /// reverse-start order so earlier offsets stay valid. + pub edits: Vec, +} + +/// A parsed `WorkspaceEdit`: per-file edit lists plus a count of +/// resource operations we deliberately did not apply (L4). +#[derive(Clone, Debug, Default)] +pub struct WorkspaceEditResponse { + /// One entry per touched document. + pub files: Vec, + /// Number of `create` / `rename` / `delete` file operations the + /// server requested that this layer does not yet apply. + pub unsupported_ops: usize, +} + +impl WorkspaceEditResponse { + /// Parse `WorkspaceEdit | null`. + /// + /// Per the LSP spec `documentChanges` supersedes `changes` when + /// both are present, so it is preferred. A `null` / shapeless + /// result yields an empty response (rename produced nothing). + #[must_use] + pub fn from_lsp_value(v: &Value) -> Self { + if let Some(dc) = v.get("documentChanges").and_then(Value::as_array) { + return Self::from_document_changes(dc); + } + if let Some(changes) = v.get("changes").and_then(Value::as_object) { + let mut files = Vec::with_capacity(changes.len()); + for (uri, edits) in changes { + files.push(FileEdits { + uri: uri.clone(), + edits: parse_edit_array(edits), + }); + } + // Object iteration order is unspecified; sort by URI so the + // applier (and tests) see a deterministic sequence. + files.sort_by(|a, b| a.uri.cmp(&b.uri)); + return Self { + files, + unsupported_ops: 0, + }; + } + Self::default() + } + + fn from_document_changes(dc: &[Value]) -> Self { + let mut files = Vec::new(); + let mut unsupported_ops = 0; + for entry in dc { + // A resource operation is tagged with `kind`; a + // TextDocumentEdit has a `textDocument` + `edits`. + if entry.get("kind").and_then(Value::as_str).is_some() { + unsupported_ops += 1; + continue; + } + let Some(uri) = entry + .get("textDocument") + .and_then(|t| t.get("uri")) + .and_then(Value::as_str) + else { + continue; + }; + let edits = entry.get("edits").map(parse_edit_array).unwrap_or_default(); + files.push(FileEdits { + uri: uri.to_owned(), + edits, + }); + } + Self { + files, + unsupported_ops, + } + } + + /// True iff there is nothing to apply and nothing was skipped. + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.iter().all(|f| f.edits.is_empty()) && self.unsupported_ops == 0 + } + + /// Total edits across every file. + #[must_use] + pub fn edit_count(&self) -> usize { + self.files.iter().map(|f| f.edits.len()).sum() + } +} + +/// Parse a `(TextEdit | AnnotatedTextEdit)[]` value into edits, +/// dropping malformed entries. `AnnotatedTextEdit` is a `TextEdit` +/// with an extra `annotationId`; the range/newText parse identically. +fn parse_edit_array(v: &Value) -> Vec { + let Some(arr) = v.as_array() else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(arr.len()); + for item in arr { + if let Some(e) = parse_text_edit(item) { + out.push(e); + } + } + out +} + +fn parse_text_edit(v: &Value) -> Option { + let range = v.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + let new_text = v.get("newText")?.as_str()?.to_owned(); + Some(TextEdit { + start_line: start.get("line")?.as_u64()? as u32, + start_col: start.get("character")?.as_u64()? as u32, + end_line: end.get("line")?.as_u64()? as u32, + end_col: end.get("character")?.as_u64()? as u32, + new_text, + }) +} + +/// Per-server, per-origin-uri rename response state. The key URI is +/// the document the rename was *requested* on (the symbol's home +/// file), not the files the edit happens to touch. +#[derive(Default)] +pub struct RenameStore { + by_key: HashMap, +} + +/// Key into [`RenameStore`]. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct RenameKey { + /// Decimal LSP server id. + pub server: String, + /// The URI the rename was requested on. + pub uri: String, +} + +impl RenameKey { + /// Construct a key. + #[must_use] + pub fn new(server: impl Into, uri: impl Into) -> Self { + Self { + server: server.into(), + uri: uri.into(), + } + } +} + +impl RenameStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace the response at `key`. + pub fn set(&mut self, key: RenameKey, response: WorkspaceEditResponse) { + self.by_key.insert(key, response); + } + + /// Drop the entry at `key`. + pub fn clear(&mut self, key: &RenameKey) { + self.by_key.remove(key); + } + + /// Look up the entry at `key`. + #[must_use] + pub fn get(&self, key: &RenameKey) -> Option<&WorkspaceEditResponse> { + self.by_key.get(key) + } +} + +/// Cheaply-cloneable shared handle. +pub type SharedRenameStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedRenameStore { + Arc::new(Mutex::new(RenameStore::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn one_edit(new_text: &str) -> Value { + json!({ + "range": { + "start": { "line": 0, "character": 3 }, + "end": { "line": 0, "character": 6 } + }, + "newText": new_text + }) + } + + #[test] + fn parses_changes_map_sorted_by_uri() { + let v = json!({ + "changes": { + "file:///b.rs": [one_edit("Y")], + "file:///a.rs": [one_edit("X"), one_edit("Z")] + } + }); + let r = WorkspaceEditResponse::from_lsp_value(&v); + assert_eq!(r.files.len(), 2); + assert_eq!(r.files[0].uri, "file:///a.rs"); + assert_eq!(r.files[0].edits.len(), 2); + assert_eq!(r.files[1].uri, "file:///b.rs"); + assert_eq!(r.edit_count(), 3); + assert_eq!(r.unsupported_ops, 0); + } + + #[test] + fn parses_document_changes_and_prefers_it_over_changes() { + let v = json!({ + "changes": { "file:///ignored.rs": [one_edit("NO")] }, + "documentChanges": [ + { + "textDocument": { "uri": "file:///a.rs", "version": 1 }, + "edits": [one_edit("A")] + } + ] + }); + let r = WorkspaceEditResponse::from_lsp_value(&v); + assert_eq!(r.files.len(), 1); + assert_eq!(r.files[0].uri, "file:///a.rs"); + assert_eq!(r.files[0].edits[0].new_text, "A"); + } + + #[test] + fn resource_ops_are_counted_not_applied() { + let v = json!({ + "documentChanges": [ + { "kind": "create", "uri": "file:///new.rs" }, + { + "textDocument": { "uri": "file:///a.rs", "version": 2 }, + "edits": [one_edit("A")] + }, + { "kind": "rename", "oldUri": "file:///a.rs", "newUri": "file:///c.rs" } + ] + }); + let r = WorkspaceEditResponse::from_lsp_value(&v); + assert_eq!(r.files.len(), 1); + assert_eq!(r.unsupported_ops, 2); + assert!(!r.is_empty()); + } + + #[test] + fn null_response_is_empty() { + let r = WorkspaceEditResponse::from_lsp_value(&Value::Null); + assert!(r.is_empty()); + assert_eq!(r.edit_count(), 0); + } + + #[test] + fn annotated_text_edit_parses_like_plain() { + let v = json!({ + "documentChanges": [{ + "textDocument": { "uri": "file:///a.rs", "version": 1 }, + "edits": [{ + "range": { + "start": { "line": 2, "character": 0 }, + "end": { "line": 2, "character": 4 } + }, + "newText": "Q", + "annotationId": "ann1" + }] + }] + }); + let r = WorkspaceEditResponse::from_lsp_value(&v); + assert_eq!(r.files[0].edits[0].new_text, "Q"); + assert_eq!(r.files[0].edits[0].start_line, 2); + } + + #[test] + fn store_set_get_clear() { + let mut s = RenameStore::new(); + let key = RenameKey::new("1", "file:///a"); + s.set( + key.clone(), + WorkspaceEditResponse { + files: vec![FileEdits { + uri: "file:///a".into(), + edits: vec![TextEdit { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 1, + new_text: "x".into(), + }], + }], + unsupported_ops: 0, + }, + ); + assert_eq!(s.get(&key).unwrap().files.len(), 1); + s.clear(&key); + assert!(s.get(&key).is_none()); + } +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 61dde86..bad4b4f 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3424,6 +3424,136 @@ fn m4_12_cross_file_go_to_definition_and_jump_back() { ); } +/// T M4.5 L2 — cross-file rename end to end through the default +/// bundle. The `rename` fake returns a `WorkspaceEdit` whose +/// `documentChanges` touch *two* files (the origin plus an env-named +/// second URI) and include one resource op. `pmacs.lsp.rename` must +/// prompt, send `textDocument/rename`, await the `WorkspaceEdit`, then +/// apply the per-file edits across both buffers, count the skipped +/// resource op, and restore the origin buffer. +#[test] +fn m4_13_rename_applies_cross_file_workspace_edit() { + use pmacs::editor::EditorState; + + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("a.rs"); + let b_path = dir.path().join("b.rs"); + // The fake's edit replaces line-0 cols 3..6; "foo" sits there. + std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); + std::fs::write(&b_path, b"abcfooxyz\n").expect("write b"); + let a_disp = a_path.display().to_string(); + let b_disp = b_path.display().to_string(); + let b_uri = format!("file://{b_disp}"); + + let mut state = EditorState::new(); + let fake = fake_lsp_path(); + + state + .lua_host + .lua() + .load(format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'rename', + PMACS_FAKE_LSP_RENAME_URI = '{b_uri}', + }}, + }}" + )) + .exec() + .expect("override rust config"); + + state + .lua_host + .lua() + .load(format!("pmacs.buffer.find_or_open('{a_disp}')")) + .exec() + .expect("open a.rs"); + + assert!( + pump_lua_flag( + &mut state, + "(function() for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end return false end)()", + 5, + ), + "fake never initialized" + ); + + // Open the rename prompt, type the new name, accept it. `accept` + // invokes the `on_accept` callback, which spawns the async + // request/apply coroutine. + state + .lua_host + .lua() + .load("pmacs.lsp.rename()") + .exec() + .expect("invoke rename"); + assert!( + state + .lua_host + .lua() + .load("return pmacs.minibuffer.is_active()") + .eval::() + .unwrap(), + "rename should have opened a minibuffer prompt" + ); + state + .lua_host + .lua() + .load("pmacs.minibuffer.set_contents('BAR'); pmacs.minibuffer.accept()") + .exec() + .expect("accept rename name"); + + // Completion signal: the active (origin) buffer's text now carries + // the rename — proves the request landed, the edit applied, and + // the origin buffer was restored. + assert!( + pump_lua_flag( + &mut state, + "(function() local b = pmacs.window.buffer() \ + return b ~= nil and b:slice(0, b:len()):find('BAR', 1, true) ~= nil end)()", + 5, + ), + "rename never applied to the origin buffer" + ); + + // Origin buffer restored. + let active: Option = state + .lua_host + .lua() + .load("return pmacs.editor.file_path()") + .eval() + .unwrap(); + assert_eq!( + active.as_deref(), + Some(a_disp.as_str()), + "rename must restore the buffer it was invoked from" + ); + + // Origin file edited in place. + let a_text: String = state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .unwrap(); + assert_eq!(a_text, "abcBARxyz\n", "a.rs should be renamed"); + + // The *second* file in the WorkspaceEdit was edited too. + let b_text: String = state + .lua_host + .lua() + .load(format!( + "pmacs.buffer.find_or_open('{b_disp}') \ + local b = pmacs.window.buffer() return b:slice(0, b:len())" + )) + .eval() + .unwrap(); + assert_eq!(b_text, "abcBARxyz\n", "b.rs should be renamed cross-file"); +} + /// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the /// hooks are defined, the namespace tables exist, the user-facing /// commands are registered with the command registry, and the default