From a71e4a0ac009e9ede5c70d03401584d4101facbb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 19 May 2026 11:11:08 -0400 Subject: [PATCH] T M4.5 L3: code actions + executeCommand + workspace/applyEdit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third cross-file layer: code actions and the server→client edit channel that executeCommand-driven actions depend on. - src/code_action.rs: normalise (Command | CodeAction)[] into one CodeActionItem (bare-Command vs nested-Command disambiguated by whether top-level `command` is a string; inline `edit` reuses rename::WorkspaceEditResponse). Store keyed (server, uri). 5 tests. - src/lsp.rs: code_action_store + accessor, ResponseRoute::CodeAction + absorb, request_code_action, request_execute_command (awaiter only — effect arrives out of band). Capabilities: codeAction (+codeActionLiteralSupport), workspace.executeCommand, and workspace.applyEdit flipped to true. - src/lua_bindings.rs: _request_code_action_raw, _request_execute_command_raw, _parse_workspace_edit (any raw WorkspaceEdit JSON -> applier input shape), pmacs.code_action.*. - pmacs_fake_lsp.rs: textDocument/codeAction arm (command action first, inline-edit action second) + workspace/executeCommand arm that emits a server→client workspace/applyEdit before responding. - builtin/runtime/lsp.lua: pmacs.lsp.code_actions (apply first action: inline edit and/or executeCommand); the applyEdit pump (chained on pmacs._async.tick, drains only attachment-server events, snapshots server ids before applying since find_or_open can mutate `attachments`, replies { applied }); lsp.code-actions command + C-c a keybind. - tests/m4_acceptance.rs: m4_14 drives the full codeAction→executeCommand→applyEdit chain end to end. Gates: lib 1273/0, m4 69/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 --- builtin/runtime/lsp.lua | 141 ++++++++++++++++++- src/bin/pmacs_fake_lsp.rs | 98 +++++++++++++ src/code_action.rs | 288 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/lsp.rs | 103 +++++++++++++- src/lua_bindings.rs | 138 ++++++++++++++++++ tests/m4_acceptance.rs | 92 ++++++++++++ 7 files changed, 854 insertions(+), 7 deletions(-) create mode 100644 src/code_action.rs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 2ec0e26..468c05d 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -5,14 +5,16 @@ -- * 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` --- / `pmacs.lsp.rename`, bound to default chords below. +-- / `pmacs.lsp.rename` / `pmacs.lsp.code_actions`, bound to +-- default chords below. -- -- 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. +-- 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. pmacs.lsp = pmacs.lsp or {} pmacs.lsp.config = pmacs.lsp.config or {} @@ -265,6 +267,8 @@ pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_sym 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) +pmacs.lsp.request_code_action = wrap_request(pmacs.lsp._request_code_action_raw) +pmacs.lsp.request_execute_command = wrap_request(pmacs.lsp._request_execute_command_raw) -- Render an `:await()` failure into a modeline-friendly reason. -- `Handle:await()` raises `{ tag = "cancelled", ... }` when the @@ -386,6 +390,65 @@ local function apply_workspace_edit(file_edits) return total, #plan end +-- T M4.5 L3 — server→client `workspace/applyEdit` pump. +-- +-- After a code action's `executeCommand`, servers (rust-analyzer, +-- gopls, …) deliver the actual change as a `workspace/applyEdit` +-- *request* — surfaced by the manager as a `request` event on the +-- server's event stream (the same "expose the request to the +-- consumer" path as `workspace/configuration`, minus the built-in +-- answer). We drain attachment servers' events each async tick, apply +-- any applyEdit through the shared applier, and reply `{ applied }`. +-- +-- Only servers in `attachments` are drained, so a test (or package) +-- that owns its own directly-spawned server and reads its events +-- itself is unaffected. Server ids are snapshotted before the loop +-- because `apply_workspace_edit` → `find_or_open` can attach a new +-- buffer mid-iteration (mutating `attachments`). +local function handle_apply_edit_requests() + local sids, seen = {}, {} + for _, rec in pairs(attachments) do + local sid = rec.server + if sid then + local k = tostring(sid) + if not seen[k] then + seen[k] = true + sids[#sids + 1] = sid + end + end + end + for _, sid in ipairs(sids) do + local ok, evs = pcall(pmacs.lsp.events_take, sid) + if ok and evs then + for _, ev in ipairs(evs) do + if ev.kind == "request" and ev.method == "workspace/applyEdit" then + local edit = ev.params and ev.params.edit + local applied, reason = false, nil + if edit then + local parsed = pmacs.lsp._parse_workspace_edit(edit) + local n, info = apply_workspace_edit(parsed.files) + if n then applied = true else reason = info end + else + reason = "missing edit" + end + local result = { applied = applied } + if not applied then result.failureReason = tostring(reason) end + pcall(pmacs.lsp.send_response, sid, ev.request_id, result) + end + end + end + end +end + +if pmacs._async and pmacs._async.tick then + local _prior_async_tick = pmacs._async.tick + pmacs._async.tick = function(...) + local ret = _prior_async_tick(...) + pcall(handle_apply_edit_requests) + return ret + end +end + -- Commands ---------------------------------------------------------------- -- Each command captures the cursor/target at invocation time, then @@ -605,6 +668,65 @@ function pmacs.lsp.rename() } end +-- T M4.5 L3 — code actions at the cursor. Requests the actions, +-- then applies the first one: an inline `edit` goes through the +-- shared WorkspaceEdit applier; a `command` is dispatched via +-- `workspace/executeCommand` (after which the server usually drives +-- the change with a server→client `workspace/applyEdit`, handled by +-- the pump installed below). A selection UI over multiple actions is +-- future UX work, like the references list and hover panel — v1 +-- acts on the first and reports how many were offered. +function pmacs.lsp.code_actions() + 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.code_action.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_code_action( + rec.server, rec.uri, line, col, line, col):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local acts = pmacs.code_action.actions(rec.server, rec.uri) + if not acts or #acts == 0 then + pmacs.editor.set_status("LSP: no code actions") + return + end + local first = acts[1] + local bits = {} + if first.has_edit then + local n, info = apply_workspace_edit(first.edit) + if not n then + pmacs.editor.set_status("LSP: code action aborted: " .. tostring(info)) + return + end + table.insert(bits, string.format("%d edit(s) / %d file(s)", n, info)) + end + if first.command then + local ok2, cerr = pcall(function() + pmacs.lsp.request_execute_command( + rec.server, first.command.command, first.command.arguments):await() + end) + if not ok2 then + pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) + return + end + table.insert(bits, "ran '" .. first.command.command .. "'") + end + local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" + pmacs.editor.set_status(string.format( + "LSP: code action '%s'%s (%d available)", + first.title, detail, #acts)) + end) +end + function pmacs.lsp.hover_at_cursor() local rec = attached_for_active() if not rec then @@ -706,6 +828,12 @@ pmacs.command.define { fn = pmacs.lsp.rename, } +pmacs.command.define { + name = "lsp.code-actions", + description = "Apply a code action for the symbol/range under the cursor (LSP).", + fn = pmacs.lsp.code_actions, +} + -- 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. @@ -728,6 +856,7 @@ pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-re 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 a", command = "lsp.code-actions" } 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 c2c50d6..d2c4d33 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -454,6 +454,104 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("textDocument/codeAction", Some(idv)) => { + // T M4.5 L3: two actions — one with an inline edit + // (replace line-0 cols 3..6 with "ED1"), one that is + // command-only (the client must `executeCommand` it, + // and we then drive the change via a server→client + // `applyEdit`). The command carries the document URI + // as its argument so the executeCommand arm knows + // what to edit. + let uri = params + .get("textDocument") + .and_then(|t| t.get("uri")) + .cloned() + .unwrap_or(serde_json::Value::Null); + let mut changes = serde_json::Map::new(); + changes.insert( + uri.as_str().unwrap_or("").to_owned(), + serde_json::json!([{ + "range": { + "start": { "line": 0, "character": 3 }, + "end": { "line": 0, "character": 6 } + }, + "newText": "ED1" + }]), + ); + // Command action first so a "apply the first action" + // client drives the executeCommand→applyEdit path; + // the inline-edit action second still exercises the + // CodeAction.edit normalisation in the store. + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": [ + { + "title": "Run server command", + "kind": "refactor", + "command": { + "title": "Run", + "command": "pmacs.fake.applyEdit", + "arguments": [uri] + } + }, + { + "title": "Inline fix", + "kind": "quickfix", + "edit": { "changes": changes } + } + ] + }); + write_frame(&mut stdout, &resp); + } + ("workspace/executeCommand", Some(idv)) => { + // T M4.5 L3: the real edit is delivered out of band + // via a server→client `workspace/applyEdit` request + // (id 9100), exactly as rust-analyzer et al. do. + // Replace line-1 cols 0..3 with "ED2". Then answer + // the original executeCommand with a null result; the + // client's reply to 9100 lands in the default arm and + // is ignored. + let cmd = params + .get("command") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if cmd == "pmacs.fake.applyEdit" { + let target = params + .get("arguments") + .and_then(serde_json::Value::as_array) + .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": { + "documentChanges": [{ + "textDocument": { "uri": target, "version": 1 }, + "edits": [{ + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 3 } + }, + "newText": "ED2" + }] + }] + } + } + }); + write_frame(&mut stdout, &apply); + } + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": serde_json::Value::Null + }); + 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/code_action.rs b/src/code_action.rs new file mode 100644 index 0000000..c70ea4a --- /dev/null +++ b/src/code_action.rs @@ -0,0 +1,288 @@ +// code_action.rs --- T M4.5 L3 LSP code actions / executeCommand. + +//! `textDocument/codeAction` response state. +//! +//! The response is `(Command | CodeAction)[] | null`. The two shapes +//! are normalised into one [`CodeActionItem`]: +//! +//! * a bare `Command` becomes an item with no `edit` and its +//! `command` populated; +//! * a `CodeAction` keeps its `title`/`kind`, an optional inline +//! [`WorkspaceEditResponse`] (`edit`), and an optional `command` +//! to run after (or instead of) the edit. +//! +//! Applying the chosen item is Lua policy (same division as +//! [`crate::rename`] / [`crate::formatting`]): an inline `edit` is fed +//! to the shared `WorkspaceEdit` applier; a `command` is dispatched via +//! `workspace/executeCommand`, after which the server typically drives +//! the real change with a server→client `workspace/applyEdit` request +//! (handled by the Lua event pump). Nothing here mutates the editor. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +use crate::rename::WorkspaceEditResponse; + +/// A server command to run via `workspace/executeCommand`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CommandRef { + /// Human-readable title. + pub title: String, + /// The command identifier the server registered. + pub command: String, + /// Opaque arguments, passed through verbatim. + pub arguments: Vec, +} + +/// One normalised code action. +#[derive(Clone, Debug, Default)] +pub struct CodeActionItem { + /// Display title. + pub title: String, + /// LSP `CodeActionKind` (e.g. `quickfix`, `refactor`), if any. + pub kind: Option, + /// Inline workspace edit, if the action carries one. Empty when + /// the action is command-only. + pub edit: WorkspaceEditResponse, + /// Command to dispatch, if any. + pub command: Option, +} + +impl CodeActionItem { + /// True iff the action carries an inline edit. + #[must_use] + pub fn has_edit(&self) -> bool { + !self.edit.is_empty() + } +} + +/// Parsed `textDocument/codeAction` response. +#[derive(Clone, Debug, Default)] +pub struct CodeActionResponse { + /// Actions in server order. + pub actions: Vec, +} + +impl CodeActionResponse { + /// Parse `(Command | CodeAction)[] | null`. + #[must_use] + pub fn from_lsp_value(v: &Value) -> Self { + let Some(arr) = v.as_array() else { + return Self::default(); + }; + let mut actions = Vec::with_capacity(arr.len()); + for item in arr { + if let Some(a) = parse_item(item) { + actions.push(a); + } + } + Self { actions } + } + + /// True iff the server offered no actions. + #[must_use] + pub fn is_empty(&self) -> bool { + self.actions.is_empty() + } +} + +fn parse_command(v: &Value) -> Option { + // A `Command` always has a string `command`. `arguments` is + // optional; `title` defaults to the command id when absent. + let command = v.get("command")?.as_str()?.to_owned(); + let title = v + .get("title") + .and_then(Value::as_str) + .unwrap_or(&command) + .to_owned(); + let arguments = v + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + Some(CommandRef { + title, + command, + arguments, + }) +} + +fn parse_item(v: &Value) -> Option { + // Disambiguate `Command` from `CodeAction`: a `Command`'s + // `command` is a string; a `CodeAction`'s `command` (when present) + // is a nested `Command` object. So a top-level *string* `command` + // means the whole entry is a bare Command. + if v.get("command").and_then(Value::as_str).is_some() { + let cmd = parse_command(v)?; + return Some(CodeActionItem { + title: cmd.title.clone(), + kind: None, + edit: WorkspaceEditResponse::default(), + command: Some(cmd), + }); + } + // Otherwise a CodeAction. `title` is required by spec; tolerate a + // missing one rather than dropping the action. + let title = v + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let kind = v.get("kind").and_then(Value::as_str).map(str::to_owned); + let edit = v + .get("edit") + .map(WorkspaceEditResponse::from_lsp_value) + .unwrap_or_default(); + let command = v.get("command").and_then(parse_command); + Some(CodeActionItem { + title, + kind, + edit, + command, + }) +} + +/// Per-server, per-uri code-action state. +#[derive(Default)] +pub struct CodeActionStore { + by_key: HashMap, +} + +/// Key into [`CodeActionStore`]. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct CodeActionKey { + /// Decimal LSP server id. + pub server: String, + /// Document URI the request was made on. + pub uri: String, +} + +impl CodeActionKey { + /// Construct a key. + #[must_use] + pub fn new(server: impl Into, uri: impl Into) -> Self { + Self { + server: server.into(), + uri: uri.into(), + } + } +} + +impl CodeActionStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace the response at `key`. + pub fn set(&mut self, key: CodeActionKey, response: CodeActionResponse) { + self.by_key.insert(key, response); + } + + /// Drop the entry at `key`. + pub fn clear(&mut self, key: &CodeActionKey) { + self.by_key.remove(key); + } + + /// Look up the entry at `key`. + #[must_use] + pub fn get(&self, key: &CodeActionKey) -> Option<&CodeActionResponse> { + self.by_key.get(key) + } +} + +/// Cheaply-cloneable shared handle. +pub type SharedCodeActionStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedCodeActionStore { + Arc::new(Mutex::new(CodeActionStore::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_bare_command() { + let v = json!([{ + "title": "Run me", + "command": "pmacs.do", + "arguments": [1, "x"] + }]); + let r = CodeActionResponse::from_lsp_value(&v); + assert_eq!(r.actions.len(), 1); + let a = &r.actions[0]; + assert_eq!(a.title, "Run me"); + assert!(!a.has_edit()); + let c = a.command.as_ref().unwrap(); + assert_eq!(c.command, "pmacs.do"); + assert_eq!(c.arguments.len(), 2); + } + + #[test] + fn parses_code_action_with_inline_edit() { + let v = json!([{ + "title": "Fix it", + "kind": "quickfix", + "edit": { + "changes": { + "file:///a.rs": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 3 } + }, + "newText": "ok" + }] + } + } + }]); + let r = CodeActionResponse::from_lsp_value(&v); + 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!(a.command.is_none()); + } + + #[test] + fn parses_code_action_with_nested_command() { + let v = json!([{ + "title": "Refactor", + "kind": "refactor", + "command": { "title": "Apply", "command": "srv.apply", "arguments": [] } + }]); + let r = CodeActionResponse::from_lsp_value(&v); + let a = &r.actions[0]; + assert!(!a.has_edit()); + assert_eq!(a.command.as_ref().unwrap().command, "srv.apply"); + } + + #[test] + fn null_response_is_empty() { + assert!(CodeActionResponse::from_lsp_value(&Value::Null).is_empty()); + } + + #[test] + fn store_set_get_clear() { + let mut s = CodeActionStore::new(); + let key = CodeActionKey::new("1", "file:///a"); + s.set( + key.clone(), + CodeActionResponse { + actions: vec![CodeActionItem { + title: "t".into(), + ..Default::default() + }], + }, + ); + assert_eq!(s.get(&key).unwrap().actions.len(), 1); + s.clear(&key); + assert!(s.get(&key).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0b76ca4..3b1dc19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod buffer; pub mod buffer_registry; pub mod builtin_packages; pub mod cell; +pub mod code_action; pub mod command; pub mod completion; pub mod completion_framework; diff --git a/src/lsp.rs b/src/lsp.rs index b8b0d46..0576f39 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -759,6 +759,10 @@ pub struct LspManager { /// 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, + /// T M4.5 L3 code-action store. Populated when a + /// `textDocument/codeAction` response lands, keyed by `(server, + /// uri)`. + code_action_store: crate::code_action::SharedCodeActionStore, /// 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. @@ -819,6 +823,9 @@ enum ResponseRoute { /// Absorb a `textDocument/rename` `WorkspaceEdit` into /// [`crate::rename::RenameStore`] at `(server, origin uri)`. Rename { uri: String }, + /// Absorb a `textDocument/codeAction` response into + /// [`crate::code_action::CodeActionStore`] at `(server, uri)`. + CodeAction { uri: String }, /// Absorb a Location-shaped nav response (references / declaration /// / typeDefinition / implementation) into /// [`crate::locations::LocationsStore`] at `(server, uri, kind)`. @@ -861,6 +868,7 @@ impl ResponseRoute { | ResponseRoute::Definition { uri } | ResponseRoute::Formatting { uri } | ResponseRoute::Rename { uri } + | ResponseRoute::CodeAction { uri } | ResponseRoute::Locations { uri, .. } | ResponseRoute::DocumentSymbol { uri } | ResponseRoute::DocumentHighlight { uri } => uri, @@ -943,6 +951,7 @@ impl LspManager { document_highlight_store: crate::document_highlight::make_shared_store(), formatting_store: crate::formatting::make_shared_store(), rename_store: crate::rename::make_shared_store(), + code_action_store: crate::code_action::make_shared_store(), pending_routes: HashMap::new(), status_tracker: crate::lsp_status::LspStatusTracker::new(), project_servers: HashMap::new(), @@ -1015,6 +1024,12 @@ impl LspManager { self.rename_store.clone() } + /// Shared code-action store (T M4.5 L3). + #[must_use] + pub fn code_action_store(&self) -> crate::code_action::SharedCodeActionStore { + self.code_action_store.clone() + } + /// T M4.8: per-server status snapshot, derived from the LSP event /// stream. The modeline reads its label from this. #[must_use] @@ -1760,6 +1775,59 @@ impl LspManager { Ok(job_id) } + /// Send `textDocument/codeAction` over `[start, end]` in `uri`. + /// `context.diagnostics` is passed through verbatim (callers feed + /// the overlapping diagnostics so quick-fixes resolve); an empty + /// slice is a valid "no diagnostics" context. The response is + /// absorbed into the code-action store at `(sid, uri)`. Returns + /// the async-runtime [`JobId`] the response will settle. + #[allow(clippy::too_many_arguments)] + pub fn request_code_action( + &mut self, + sid: LspServerId, + uri: impl Into, + start_line: u32, + start_col: u32, + end_line: u32, + end_col: u32, + diagnostics: &[Value], + ) -> Result { + let uri = uri.into(); + let params = json!({ + "textDocument": { "uri": uri.clone() }, + "range": { + "start": { "line": start_line, "character": start_col }, + "end": { "line": end_line, "character": end_col }, + }, + "context": { "diagnostics": diagnostics }, + }); + let req_id = self.send_request(sid, "textDocument/codeAction", params)?; + let job_id = self.register_awaiter(sid, req_id, "textDocument/codeAction", &uri); + self.pending_routes + .insert((sid, req_id), ResponseRoute::CodeAction { uri }); + Ok(job_id) + } + + /// Send `workspace/executeCommand`. No response route is + /// registered: the command result is usually `null` and the real + /// effect arrives as a server→client `workspace/applyEdit` (the + /// Lua event pump applies it). The returned awaiter still settles + /// when the command response lands so callers can sequence work + /// after it. Returns the async-runtime [`JobId`]. + pub fn request_execute_command( + &mut self, + sid: LspServerId, + command: impl Into, + arguments: &[Value], + ) -> Result { + let command = command.into(); + let params = json!({ "command": command, "arguments": arguments }); + let req_id = self.send_request(sid, "workspace/executeCommand", params)?; + // Awaiter only — `absorb_routed_response` has no CodeAction- + // result store and the effect is delivered out of band. + Ok(self.register_awaiter(sid, req_id, "workspace/executeCommand", &command)) + } + /// Reply to a server-initiated request. pub fn send_response( &mut self, @@ -2241,6 +2309,9 @@ impl LspManager { ); } + // One arm per `ResponseRoute` variant — grows by a few lines with + // each new typed store; the dispatch stays a flat match. + #[allow(clippy::too_many_lines)] fn absorb_routed_response(&self, sid: LspServerId, route: &ResponseRoute, result: &Value) { // T M4.5 Option B: normalise every `Position` in the response // to pmacs byte offsets *before* the typed store parses it, so @@ -2346,6 +2417,15 @@ impl LspManager { .expect("rename store mutex poisoned"); guard.set(key, resp); } + ResponseRoute::CodeAction { uri } => { + let resp = crate::code_action::CodeActionResponse::from_lsp_value(result); + let key = crate::code_action::CodeActionKey::new(server_key, uri.clone()); + let mut guard = self + .code_action_store + .lock() + .expect("code action store mutex poisoned"); + guard.set(key, resp); + } } } @@ -2734,7 +2814,13 @@ fn default_capabilities() -> Value { "positionEncodings": ["utf-8", "utf-16"], }, "workspace": { - "applyEdit": false, + // T M4.5 L3: pmacs applies server→client `workspace/ + // applyEdit` requests via the Lua WorkspaceEdit applier + // (surfaced as a request event, answered with + // `{ applied }`). executeCommand-driven code actions + // depend on this. + "applyEdit": true, + "executeCommand": { "dynamicRegistration": false }, // T M4.5: pmacs answers server→client `workspace/configuration` // pull requests from the per-server `settings` (see // `handle_request`). gopls / pyright / clangd all pull @@ -2769,6 +2855,21 @@ fn default_capabilities() -> Value { // `prepareRename` round-trip (L2 scope); the symbol range // comes from the cursor position. "rename": { "dynamicRegistration": false, "prepareSupport": false }, + // T M4.5 L3: code actions. `codeActionLiteralSupport` + // tells servers we accept the richer `CodeAction` shape + // (title/kind/edit/command), not just bare `Command`s. + "codeAction": { + "dynamicRegistration": false, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix", "refactor", "refactor.extract", + "refactor.inline", "refactor.rewrite", + "source", "source.organizeImports", + ], + }, + }, + }, "publishDiagnostics": { "relatedInformation": true }, }, }) diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 7b8b961..b58c508 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7403,6 +7403,72 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + { + let m = manager.clone(); + lsp_mod.set( + "_request_code_action_raw", + lua.create_function( + move |_, + (id, uri, sl, sc, el, ec): ( + LspServerIdLua, + String, + u32, + u32, + u32, + u32, + )| { + // v1 sends an empty diagnostics context (point/ + // range actions). Diagnostic-driven quick-fixes + // are a later refinement of this same call. + let job_id = m + .borrow_mut() + .request_code_action(id.0, uri, sl, sc, el, ec, &[]) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + + { + let m = manager.clone(); + lsp_mod.set( + "_request_execute_command_raw", + lua.create_function( + move |_, (id, command, args): (LspServerIdLua, String, Option)| { + let arguments = match args { + Some(v) => lua_to_json(v)?.as_array().cloned().unwrap_or_default(), + None => Vec::new(), + }; + let job_id = m + .borrow_mut() + .request_execute_command(id.0, command, &arguments) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + + { + // 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. + 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)?; + Ok(out) + })?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -7639,6 +7705,7 @@ pub fn make_lsp_manager( install_document_highlight(lua, &manager)?; install_formatting(lua, &manager)?; install_rename(lua, &manager)?; + install_code_action(lua, &manager)?; Ok(manager) } @@ -8433,6 +8500,7 @@ pub fn install_diag(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { // pmacs.completion / pmacs.hover / pmacs.signature: T M4.7 surfaces // --------------------------------------------------------------------------- +use crate::code_action::{CodeActionItem, CodeActionKey}; use crate::completion::{CompletionItem, CompletionItemKind, CompletionKey, CompletionTriggers}; use crate::definition::{DefinitionKey, DefinitionLocation, DefinitionResponse}; use crate::document_highlight::{DocumentHighlightKey, Highlight}; @@ -9182,6 +9250,76 @@ pub fn install_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> Ok(()) } +fn code_action_item_to_lua(lua: &Lua, a: &CodeActionItem) -> mlua::Result { + let t = lua.create_table_with_capacity(0, 4)?; + t.set("title", a.title.as_str())?; + if let Some(k) = a.kind.as_deref() { + 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)?)?; + if let Some(c) = a.command.as_ref() { + let ct = lua.create_table_with_capacity(0, 3)?; + ct.set("command", c.command.as_str())?; + ct.set("title", c.title.as_str())?; + let args = lua.create_table_with_capacity(c.arguments.len(), 0)?; + for (i, v) in c.arguments.iter().enumerate() { + args.set(i + 1, json_to_lua(lua, v)?)?; + } + ct.set("arguments", args)?; + t.set("command", ct)?; + } + Ok(t) +} + +/// Install `pmacs.code_action.*` (T M4.5 L3). `actions(sid, uri)` +/// returns `{ { title, kind?, has_edit, edit = { … }, command? }, … }` +/// in server order; `clear(sid, uri)` drops the entry. +pub fn install_code_action(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let m = lua.create_table()?; + + { + let mgr = manager.clone(); + m.set( + "actions", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().code_action_store(); + let guard = store_handle + .lock() + .expect("code action store mutex poisoned"); + let key = CodeActionKey::new(id.0.raw().to_string(), uri); + let out = lua.create_table()?; + if let Some(r) = guard.get(&key) { + for (i, a) in r.actions.iter().enumerate() { + out.set(i + 1, code_action_item_to_lua(lua, a)?)?; + } + } + Ok(Value::Table(out)) + })?, + )?; + } + + { + let mgr = manager.clone(); + m.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().code_action_store(); + let mut guard = store_handle + .lock() + .expect("code action store mutex poisoned"); + guard.clear(&CodeActionKey::new(id.0.raw().to_string(), uri)); + Ok(()) + })?, + )?; + } + + pmacs.set("code_action", m)?; + Ok(()) +} + // --------------------------------------------------------------------------- // pmacs.project: project model (T M4.9) // --------------------------------------------------------------------------- diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index bad4b4f..d3cbe50 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3554,6 +3554,98 @@ fn m4_13_rename_applies_cross_file_workspace_edit() { assert_eq!(b_text, "abcBARxyz\n", "b.rs should be renamed cross-file"); } +/// T M4.5 L3 — code action → `workspace/executeCommand` → +/// server-initiated `workspace/applyEdit`, end to end through the +/// default bundle. The `codeaction` fake offers a command action +/// first; `pmacs.lsp.code_actions` dispatches it via +/// `executeCommand`, the fake answers with a server→client +/// `workspace/applyEdit` request, and the Lua applyEdit pump must +/// apply that edit and reply `{ applied = true }`. Success is +/// observable as the buffer mutation the out-of-band edit performed. +#[test] +fn m4_14_code_action_command_drives_apply_edit() { + use pmacs::editor::EditorState; + + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("a.rs"); + // Line 0 is the codeAction range anchor; the executeCommand's + // applyEdit rewrites line-1 cols 0..3 ("___" -> "ED2"). + std::fs::write(&a_path, b"abcfooxyz\n___zzz\n").expect("write a"); + let a_disp = a_path.display().to_string(); + + 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 = 'codeaction' }}, + }}" + )) + .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"); + + // The applyEdit pump runs on the async tick; it applies the + // server's out-of-band edit, turning line 1 "___zzz" -> "ED2zzz". + assert!( + pump_lua_flag( + &mut state, + "(function() local b = pmacs.window.buffer() \ + return b ~= nil and b:slice(0, b:len()):find('ED2', 1, true) ~= nil end)()", + 5, + ), + "executeCommand→applyEdit never mutated the buffer" + ); + + let text: String = state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .unwrap(); + assert_eq!( + text, "abcfooxyz\nED2zzz\n", + "only the applyEdit (line 1) should have applied; line 0 untouched" + ); + + // The applier restored / kept the origin buffer active. + 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())); +} + /// 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