Merge pull request #20 from levineuwirth/lsp-code-actions
This commit is contained in:
commit
21ff334bcc
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
103
src/lsp.rs
103
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<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 },
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue