Merge pull request #20 from levineuwirth/lsp-code-actions

This commit is contained in:
Levi Neuwirth 2026-05-19 15:12:14 +00:00 committed by GitHub
commit 21ff334bcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 854 additions and 7 deletions

View File

@ -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" }

View File

@ -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

288
src/code_action.rs Normal file
View File

@ -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<Value>,
}
/// 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<String>,
/// 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<CommandRef>,
}
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<CodeActionItem>,
}
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<CommandRef> {
// 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<CodeActionItem> {
// 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<CodeActionKey, CodeActionResponse>,
}
/// 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<String>, uri: impl Into<String>) -> 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<Mutex<CodeActionStore>>;
/// 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());
}
}

View File

@ -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;

View File

@ -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<String>,
start_line: u32,
start_col: u32,
end_line: u32,
end_col: u32,
diagnostics: &[Value],
) -> Result<JobId, String> {
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<String>,
arguments: &[Value],
) -> Result<JobId, String> {
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 },
},
})

View File

@ -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<Value>)| {
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<Table> {
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)
// ---------------------------------------------------------------------------

View File

@ -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<String> = 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