Merge pull request #27 from levineuwirth/lsp-prepare-rename
T M4.5: textDocument/prepareRename (backlog item 3)
This commit is contained in:
commit
64427befac
|
|
@ -12,7 +12,8 @@
|
|||
-- Scope: one server per language across all buffers; async-await
|
||||
-- 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
|
||||
-- (L2) with `textDocument/prepareRename` gating when the server
|
||||
-- supports it, code actions + `workspace/executeCommand` + server→client
|
||||
-- `workspace/applyEdit` (L3), ordered resource-op edits
|
||||
-- (create/rename/delete file) with buffer-registry reconciliation
|
||||
-- (L4), inlay hints, and semantic tokens (each data + modeline;
|
||||
|
|
@ -272,6 +273,7 @@ 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_prepare_rename = wrap_request(pmacs.lsp._request_prepare_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)
|
||||
pmacs.lsp.request_inlay_hint = wrap_request(pmacs.lsp._request_inlay_hint_raw)
|
||||
|
|
@ -784,12 +786,17 @@ function pmacs.lsp.format_buffer()
|
|||
end)
|
||||
end
|
||||
|
||||
-- T M4.5 L2 — rename the symbol under the cursor. Prompts for the new
|
||||
-- name in the minibuffer; on accept, sends `textDocument/rename`,
|
||||
-- awaits the `WorkspaceEdit`, and drives it through the multi-file
|
||||
-- applier. The position is captured *before* the prompt opens so the
|
||||
-- request still targets the original symbol even though the minibuffer
|
||||
-- session moved focus.
|
||||
-- T M4.5 — rename the symbol under the cursor.
|
||||
--
|
||||
-- When the server advertises `renameProvider.prepareProvider`, a
|
||||
-- `textDocument/prepareRename` round-trip runs first: it gates the
|
||||
-- prompt (a `null` result means "not renameable here" — abort with a
|
||||
-- status, never open the prompt) and pre-fills the placeholder the
|
||||
-- server suggests. Servers that don't advertise prepare (or advertise
|
||||
-- `renameProvider: true`) skip straight to the prompt — the original
|
||||
-- L2 behavior, unchanged. The cursor position is captured *before*
|
||||
-- the prompt opens so the request still targets the original symbol
|
||||
-- even though the minibuffer session moved focus.
|
||||
function pmacs.lsp.rename()
|
||||
local rec = attached_for_active()
|
||||
if not rec then
|
||||
|
|
@ -798,48 +805,78 @@ function pmacs.lsp.rename()
|
|||
end
|
||||
local line = pmacs.editor.cursor_line()
|
||||
local col = pmacs.editor.cursor_col()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Rename symbol to: ",
|
||||
on_cancel = function()
|
||||
pmacs.editor.set_status("LSP: rename cancelled")
|
||||
end,
|
||||
on_accept = function(new_name)
|
||||
if not new_name or new_name == "" then
|
||||
pmacs.editor.set_status("LSP: rename needs a new name")
|
||||
return
|
||||
end
|
||||
pmacs.rename.clear(rec.server, rec.uri)
|
||||
pmacs.async(function()
|
||||
local ok, err = pcall(function()
|
||||
pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await()
|
||||
|
||||
local function open_prompt(initial)
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Rename symbol to: ",
|
||||
initial = initial,
|
||||
on_cancel = function()
|
||||
pmacs.editor.set_status("LSP: rename cancelled")
|
||||
end,
|
||||
on_accept = function(new_name)
|
||||
if not new_name or new_name == "" then
|
||||
pmacs.editor.set_status("LSP: rename needs a new name")
|
||||
return
|
||||
end
|
||||
pmacs.rename.clear(rec.server, rec.uri)
|
||||
pmacs.async(function()
|
||||
local ok, err = pcall(function()
|
||||
pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await()
|
||||
end)
|
||||
if not ok then
|
||||
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||
return
|
||||
end
|
||||
local ops = pmacs.rename.ops(rec.server, rec.uri)
|
||||
if not ops or #ops == 0 then
|
||||
pmacs.editor.set_status("LSP: rename produced no edits")
|
||||
return
|
||||
end
|
||||
local n, files, res = apply_workspace_edit(ops)
|
||||
if not n then
|
||||
-- Preflight rejected it; nothing was mutated.
|
||||
pmacs.editor.set_status("LSP: rename aborted: " .. tostring(files))
|
||||
return
|
||||
end
|
||||
local msg = string.format(
|
||||
"LSP: renamed — %d edit%s across %d file%s",
|
||||
n, (n == 1 and "" or "s"),
|
||||
files, (files == 1 and "" or "s"))
|
||||
if res and res > 0 then
|
||||
msg = msg .. string.format(
|
||||
" (+%d file op%s)", res, (res == 1 and "" or "s"))
|
||||
end
|
||||
pmacs.editor.set_status(msg)
|
||||
end)
|
||||
if not ok then
|
||||
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||
return
|
||||
end
|
||||
local ops = pmacs.rename.ops(rec.server, rec.uri)
|
||||
if not ops or #ops == 0 then
|
||||
pmacs.editor.set_status("LSP: rename produced no edits")
|
||||
return
|
||||
end
|
||||
local n, files, res = apply_workspace_edit(ops)
|
||||
if not n then
|
||||
-- Preflight rejected it; nothing was mutated.
|
||||
pmacs.editor.set_status("LSP: rename aborted: " .. tostring(files))
|
||||
return
|
||||
end
|
||||
local msg = string.format(
|
||||
"LSP: renamed — %d edit%s across %d file%s",
|
||||
n, (n == 1 and "" or "s"),
|
||||
files, (files == 1 and "" or "s"))
|
||||
if res and res > 0 then
|
||||
msg = msg .. string.format(
|
||||
" (+%d file op%s)", res, (res == 1 and "" or "s"))
|
||||
end
|
||||
pmacs.editor.set_status(msg)
|
||||
end)
|
||||
end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Gate on the server advertising prepareRename. `renameProvider`
|
||||
-- is `boolean | { prepareProvider?: boolean }`.
|
||||
local caps = pmacs.lsp.capabilities(rec.server)
|
||||
local rp = caps and caps.renameProvider
|
||||
if not (type(rp) == "table" and rp.prepareProvider == true) then
|
||||
open_prompt(nil)
|
||||
return
|
||||
end
|
||||
|
||||
pmacs.prepare_rename.clear(rec.server, rec.uri)
|
||||
pmacs.async(function()
|
||||
local ok, err = pcall(function()
|
||||
pmacs.lsp.request_prepare_rename(rec.server, rec.uri, line, col):await()
|
||||
end)
|
||||
if not ok then
|
||||
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||
return
|
||||
end
|
||||
local pr = pmacs.prepare_rename.result(rec.server, rec.uri)
|
||||
if not pr or not pr.allowed then
|
||||
pmacs.editor.set_status("LSP: cannot rename here")
|
||||
return
|
||||
end
|
||||
open_prompt(pr.placeholder)
|
||||
end)
|
||||
end
|
||||
|
||||
-- T M4.5 L3 — code actions at the cursor. Requests the actions,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,13 @@ fn main() {
|
|||
resp["result"]["capabilities"]["positionEncoding"] =
|
||||
serde_json::Value::from("utf-16");
|
||||
}
|
||||
// T M4.5: advertise prepareRename only in the
|
||||
// prepare-* modes, so the default `rename` mode keeps
|
||||
// exercising the no-prepare path.
|
||||
if mode == "prepare" || mode == "preprefuse" {
|
||||
resp["result"]["capabilities"]["renameProvider"] =
|
||||
serde_json::json!({ "prepareProvider": true });
|
||||
}
|
||||
write_frame(&mut stdout, &resp);
|
||||
if mode == "crash" {
|
||||
crashed_after_init = true;
|
||||
|
|
@ -430,6 +437,28 @@ fn main() {
|
|||
});
|
||||
write_frame(&mut stdout, &resp);
|
||||
}
|
||||
("textDocument/prepareRename", Some(idv)) => {
|
||||
// T M4.5: `preprefuse` → null (not renameable here);
|
||||
// otherwise the `{ range, placeholder }` shape over
|
||||
// the line-0 cols 3..6 span ("foo").
|
||||
let result = if mode == "preprefuse" {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"range": {
|
||||
"start": { "line": 0, "character": 3 },
|
||||
"end": { "line": 0, "character": 6 }
|
||||
},
|
||||
"placeholder": "foo"
|
||||
})
|
||||
};
|
||||
let resp = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": idv,
|
||||
"result": result
|
||||
});
|
||||
write_frame(&mut stdout, &resp);
|
||||
}
|
||||
("textDocument/rename", Some(idv)) => {
|
||||
// T M4.5 L2: reply with a `WorkspaceEdit`. The edit
|
||||
// replaces the 3-char span at line 0, cols 3..6 with
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ pub mod overlay;
|
|||
pub mod overlay_color;
|
||||
pub mod overlay_paint;
|
||||
pub mod packages;
|
||||
pub mod prepare_rename;
|
||||
pub mod presence;
|
||||
pub mod process;
|
||||
pub mod project;
|
||||
|
|
|
|||
68
src/lsp.rs
68
src/lsp.rs
|
|
@ -770,6 +770,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 prepareRename store. Populated when a
|
||||
/// `textDocument/prepareRename` response lands, keyed by `(server,
|
||||
/// uri)`.
|
||||
prepare_rename_store: crate::prepare_rename::SharedPrepareRenameStore,
|
||||
/// T M4.5 L3 code-action store. Populated when a
|
||||
/// `textDocument/codeAction` response lands, keyed by `(server,
|
||||
/// uri)`.
|
||||
|
|
@ -842,6 +846,10 @@ enum ResponseRoute {
|
|||
/// Absorb a `textDocument/rename` `WorkspaceEdit` into
|
||||
/// [`crate::rename::RenameStore`] at `(server, origin uri)`.
|
||||
Rename { uri: String },
|
||||
/// Absorb a `textDocument/prepareRename` response into
|
||||
/// [`crate::prepare_rename::PrepareRenameStore`] at `(server,
|
||||
/// uri)`.
|
||||
PrepareRename { uri: String },
|
||||
/// Absorb a `textDocument/codeAction` response into
|
||||
/// [`crate::code_action::CodeActionStore`] at `(server, uri)`.
|
||||
CodeAction { uri: String },
|
||||
|
|
@ -898,6 +906,7 @@ impl ResponseRoute {
|
|||
| ResponseRoute::Definition { uri }
|
||||
| ResponseRoute::Formatting { uri }
|
||||
| ResponseRoute::Rename { uri }
|
||||
| ResponseRoute::PrepareRename { uri }
|
||||
| ResponseRoute::CodeAction { uri }
|
||||
| ResponseRoute::InlayHint { uri }
|
||||
| ResponseRoute::SemanticTokens { uri }
|
||||
|
|
@ -984,6 +993,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(),
|
||||
prepare_rename_store: crate::prepare_rename::make_shared_store(),
|
||||
code_action_store: crate::code_action::make_shared_store(),
|
||||
inlay_hint_store: crate::inlay_hint::make_shared_store(),
|
||||
semantic_token_store: crate::semantic_tokens::make_shared_store(),
|
||||
|
|
@ -1059,6 +1069,12 @@ impl LspManager {
|
|||
self.rename_store.clone()
|
||||
}
|
||||
|
||||
/// Shared prepareRename store (T M4.5).
|
||||
#[must_use]
|
||||
pub fn prepare_rename_store(&self) -> crate::prepare_rename::SharedPrepareRenameStore {
|
||||
self.prepare_rename_store.clone()
|
||||
}
|
||||
|
||||
/// Shared code-action store (T M4.5 L3).
|
||||
#[must_use]
|
||||
pub fn code_action_store(&self) -> crate::code_action::SharedCodeActionStore {
|
||||
|
|
@ -1861,6 +1877,30 @@ impl LspManager {
|
|||
Ok(job_id)
|
||||
}
|
||||
|
||||
/// Send `textDocument/prepareRename` for the symbol at `(line,
|
||||
/// col)` in `uri`. The response (renameable? + extent +
|
||||
/// placeholder) is absorbed into the prepareRename store at
|
||||
/// `(sid, uri)`. Returns the async-runtime [`JobId`] the response
|
||||
/// will settle.
|
||||
pub fn request_prepare_rename(
|
||||
&mut self,
|
||||
sid: LspServerId,
|
||||
uri: impl Into<String>,
|
||||
line: u32,
|
||||
col: u32,
|
||||
) -> Result<JobId, String> {
|
||||
let uri = uri.into();
|
||||
let params = json!({
|
||||
"textDocument": { "uri": uri.clone() },
|
||||
"position": { "line": line, "character": col },
|
||||
});
|
||||
let req_id = self.send_request(sid, "textDocument/prepareRename", params)?;
|
||||
let job_id = self.register_awaiter(sid, req_id, "textDocument/prepareRename", &uri);
|
||||
self.pending_routes
|
||||
.insert((sid, req_id), ResponseRoute::PrepareRename { uri });
|
||||
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
|
||||
|
|
@ -2608,6 +2648,15 @@ impl LspManager {
|
|||
.expect("rename store mutex poisoned");
|
||||
guard.set(key, resp);
|
||||
}
|
||||
ResponseRoute::PrepareRename { uri } => {
|
||||
let resp = crate::prepare_rename::PrepareRenameResponse::from_lsp_value(result);
|
||||
let key = crate::prepare_rename::PrepareRenameKey::new(server_key, uri.clone());
|
||||
let mut guard = self
|
||||
.prepare_rename_store
|
||||
.lock()
|
||||
.expect("prepare 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());
|
||||
|
|
@ -3082,11 +3131,20 @@ fn default_capabilities() -> Value {
|
|||
"signatureHelp": { "dynamicRegistration": false },
|
||||
"definition": { "dynamicRegistration": false, "linkSupport": true },
|
||||
"formatting": { "dynamicRegistration": false },
|
||||
// T M4.5 L2: client-side rename. `prepareSupport: false`
|
||||
// — pmacs sends `textDocument/rename` directly without a
|
||||
// `prepareRename` round-trip (L2 scope); the symbol range
|
||||
// comes from the cursor position.
|
||||
"rename": { "dynamicRegistration": false, "prepareSupport": false },
|
||||
// T M4.5: client-side rename. `prepareSupport: true` —
|
||||
// the rename flow does a `textDocument/prepareRename`
|
||||
// round-trip first *when the server advertises
|
||||
// `renameProvider.prepareProvider`* (it gates the prompt
|
||||
// and pre-fills the placeholder); otherwise it sends
|
||||
// `textDocument/rename` straight from the cursor.
|
||||
// `prepareSupportDefaultBehavior: 1` (Identifier) tells
|
||||
// the server we can handle the `{ defaultBehavior }`
|
||||
// shape (compute the word range ourselves).
|
||||
"rename": {
|
||||
"dynamicRegistration": false,
|
||||
"prepareSupport": true,
|
||||
"prepareSupportDefaultBehavior": 1,
|
||||
},
|
||||
// T M4.5 L3: code actions. `codeActionLiteralSupport`
|
||||
// tells servers we accept the richer `CodeAction` shape
|
||||
// (title/kind/edit/command), not just bare `Command`s.
|
||||
|
|
|
|||
|
|
@ -7502,6 +7502,22 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
lsp_mod.set(
|
||||
"_request_prepare_rename_raw",
|
||||
lua.create_function(
|
||||
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
||||
let job_id = m
|
||||
.borrow_mut()
|
||||
.request_prepare_rename(id.0, uri, line, col)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(job_id)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
lsp_mod.set(
|
||||
|
|
@ -7881,6 +7897,7 @@ pub fn make_lsp_manager(
|
|||
install_document_highlight(lua, &manager)?;
|
||||
install_formatting(lua, &manager)?;
|
||||
install_rename(lua, &manager)?;
|
||||
install_prepare_rename(lua, &manager)?;
|
||||
install_code_action(lua, &manager)?;
|
||||
install_inlay_hint(lua, &manager)?;
|
||||
install_semantic_tokens(lua, &manager)?;
|
||||
|
|
@ -8686,6 +8703,7 @@ use crate::formatting::{FormattingKey, FormattingResponse, TextEdit};
|
|||
use crate::hover::{Hover, HoverKey};
|
||||
use crate::inlay_hint::{InlayHint as LspInlayHint, InlayHintKey};
|
||||
use crate::locations::{LocationKind, LocationsKey};
|
||||
use crate::prepare_rename::PrepareRenameKey;
|
||||
use crate::rename::{RenameKey, WorkspaceEditResponse, WorkspaceOp};
|
||||
use crate::semantic_tokens::{
|
||||
SemanticToken as LspSemanticToken, SemanticTokenKey, SemanticTokensLegend,
|
||||
|
|
@ -9496,6 +9514,64 @@ pub fn install_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Install `pmacs.prepare_rename.*` (T M4.5). `result(sid, uri)`
|
||||
/// returns `{ allowed, placeholder?, start_line?, start_col?,
|
||||
/// end_line?, end_col? }` for the last `textDocument/prepareRename`,
|
||||
/// or nil if none landed; `clear(sid, uri)` drops the entry. The
|
||||
/// rename flow reads `allowed` to gate the prompt and `placeholder`
|
||||
/// to pre-fill it.
|
||||
pub fn install_prepare_rename(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||
let m = lua.create_table()?;
|
||||
|
||||
{
|
||||
let mgr = manager.clone();
|
||||
m.set(
|
||||
"result",
|
||||
lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| {
|
||||
let store_handle = mgr.borrow().prepare_rename_store();
|
||||
let guard = store_handle
|
||||
.lock()
|
||||
.expect("prepare rename store mutex poisoned");
|
||||
let key = PrepareRenameKey::new(id.0.raw().to_string(), uri);
|
||||
let Some(r) = guard.get(&key) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let t = lua.create_table_with_capacity(0, 6)?;
|
||||
t.set("allowed", r.allowed)?;
|
||||
if let Some(p) = r.placeholder.as_deref() {
|
||||
t.set("placeholder", p)?;
|
||||
}
|
||||
if let Some((sl, sc, el, ec)) = r.range {
|
||||
t.set("start_line", sl)?;
|
||||
t.set("start_col", sc)?;
|
||||
t.set("end_line", el)?;
|
||||
t.set("end_col", ec)?;
|
||||
}
|
||||
Ok(Value::Table(t))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let mgr = manager.clone();
|
||||
m.set(
|
||||
"clear",
|
||||
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
|
||||
let store_handle = mgr.borrow().prepare_rename_store();
|
||||
let mut guard = store_handle
|
||||
.lock()
|
||||
.expect("prepare rename store mutex poisoned");
|
||||
guard.clear(&PrepareRenameKey::new(id.0.raw().to_string(), uri));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
pmacs.set("prepare_rename", m)?;
|
||||
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())?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
// prepare_rename.rs --- T M4.5 LSP textDocument/prepareRename.
|
||||
|
||||
//! `textDocument/prepareRename` response state.
|
||||
//!
|
||||
//! Before prompting for a new name, the client can ask the server
|
||||
//! whether the symbol at the cursor is renameable and what its extent
|
||||
//! is. The response is a union:
|
||||
//!
|
||||
//! * `null` — rename is **not** valid here.
|
||||
//! * `Range` — the symbol's range.
|
||||
//! * `{ range, placeholder }` — range plus a suggested initial
|
||||
//! value for the rename prompt.
|
||||
//! * `{ defaultBehavior: bool }` — the server defers the range
|
||||
//! computation to the client (use the word under the cursor).
|
||||
//!
|
||||
//! All shapes collapse into [`PrepareRenameResponse`]: `allowed` (the
|
||||
//! one bit the rename flow gates on), an optional `placeholder` to
|
||||
//! pre-fill the prompt, and an optional `range`. Like every other LSP
|
||||
//! feature this is data only — the rename flow in `lsp.lua` reads the
|
||||
//! store and decides whether to open the prompt.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Parsed `textDocument/prepareRename` response.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PrepareRenameResponse {
|
||||
/// False iff the server returned `null` (or `defaultBehavior:
|
||||
/// false`) — rename must not proceed.
|
||||
pub allowed: bool,
|
||||
/// Suggested prompt pre-fill, when the server sent one.
|
||||
pub placeholder: Option<String>,
|
||||
/// The symbol range `(start_line, start_col, end_line, end_col)`
|
||||
/// when the server sent one (absent for the `defaultBehavior`
|
||||
/// shape — the client uses the cursor word).
|
||||
pub range: Option<(u32, u32, u32, u32)>,
|
||||
}
|
||||
|
||||
fn parse_range(v: &Value) -> Option<(u32, u32, u32, u32)> {
|
||||
let start = v.get("start")?;
|
||||
let end = v.get("end")?;
|
||||
Some((
|
||||
start.get("line")?.as_u64()? as u32,
|
||||
start.get("character")?.as_u64()? as u32,
|
||||
end.get("line")?.as_u64()? as u32,
|
||||
end.get("character")?.as_u64()? as u32,
|
||||
))
|
||||
}
|
||||
|
||||
impl PrepareRenameResponse {
|
||||
/// Parse the `Range | { range, placeholder } | { defaultBehavior }
|
||||
/// | null` union. An unrecognised non-null shape is treated as
|
||||
/// "not allowed" rather than guessed at.
|
||||
#[must_use]
|
||||
pub fn from_lsp_value(v: &Value) -> Self {
|
||||
if v.is_null() {
|
||||
return Self::default();
|
||||
}
|
||||
// `{ defaultBehavior: bool }` — allowed iff the bool is true;
|
||||
// no explicit range (client uses the cursor word).
|
||||
if let Some(b) = v.get("defaultBehavior").and_then(Value::as_bool) {
|
||||
return Self {
|
||||
allowed: b,
|
||||
placeholder: None,
|
||||
range: None,
|
||||
};
|
||||
}
|
||||
// `{ range, placeholder? }`
|
||||
if let Some(r) = v.get("range") {
|
||||
return Self {
|
||||
allowed: true,
|
||||
placeholder: v
|
||||
.get("placeholder")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
range: parse_range(r),
|
||||
};
|
||||
}
|
||||
// Bare `Range` (has `start` & `end`).
|
||||
if let Some(rng) = parse_range(v) {
|
||||
return Self {
|
||||
allowed: true,
|
||||
placeholder: None,
|
||||
range: Some(rng),
|
||||
};
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-server, per-uri prepareRename state.
|
||||
#[derive(Default)]
|
||||
pub struct PrepareRenameStore {
|
||||
by_key: HashMap<PrepareRenameKey, PrepareRenameResponse>,
|
||||
}
|
||||
|
||||
/// Key into [`PrepareRenameStore`].
|
||||
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
|
||||
pub struct PrepareRenameKey {
|
||||
/// Decimal LSP server id.
|
||||
pub server: String,
|
||||
/// Document URI the request was made on.
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
impl PrepareRenameKey {
|
||||
/// 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 PrepareRenameStore {
|
||||
/// Empty store.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace the response at `key`.
|
||||
pub fn set(&mut self, key: PrepareRenameKey, response: PrepareRenameResponse) {
|
||||
self.by_key.insert(key, response);
|
||||
}
|
||||
|
||||
/// Drop the entry at `key`.
|
||||
pub fn clear(&mut self, key: &PrepareRenameKey) {
|
||||
self.by_key.remove(key);
|
||||
}
|
||||
|
||||
/// Look up the entry at `key`.
|
||||
#[must_use]
|
||||
pub fn get(&self, key: &PrepareRenameKey) -> Option<&PrepareRenameResponse> {
|
||||
self.by_key.get(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheaply-cloneable shared handle.
|
||||
pub type SharedPrepareRenameStore = Arc<Mutex<PrepareRenameStore>>;
|
||||
|
||||
/// Build a fresh shared store.
|
||||
#[must_use]
|
||||
pub fn make_shared_store() -> SharedPrepareRenameStore {
|
||||
Arc::new(Mutex::new(PrepareRenameStore::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn null_is_not_allowed() {
|
||||
let r = PrepareRenameResponse::from_lsp_value(&Value::Null);
|
||||
assert!(!r.allowed);
|
||||
assert!(r.placeholder.is_none());
|
||||
assert!(r.range.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_range_is_allowed() {
|
||||
let v = json!({
|
||||
"start": { "line": 2, "character": 4 },
|
||||
"end": { "line": 2, "character": 9 }
|
||||
});
|
||||
let r = PrepareRenameResponse::from_lsp_value(&v);
|
||||
assert!(r.allowed);
|
||||
assert_eq!(r.range, Some((2, 4, 2, 9)));
|
||||
assert!(r.placeholder.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_with_placeholder() {
|
||||
let v = json!({
|
||||
"range": {
|
||||
"start": { "line": 0, "character": 3 },
|
||||
"end": { "line": 0, "character": 6 }
|
||||
},
|
||||
"placeholder": "foo"
|
||||
});
|
||||
let r = PrepareRenameResponse::from_lsp_value(&v);
|
||||
assert!(r.allowed);
|
||||
assert_eq!(r.placeholder.as_deref(), Some("foo"));
|
||||
assert_eq!(r.range, Some((0, 3, 0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_behavior_true_allowed_no_range() {
|
||||
let r = PrepareRenameResponse::from_lsp_value(&json!({ "defaultBehavior": true }));
|
||||
assert!(r.allowed);
|
||||
assert!(r.range.is_none());
|
||||
assert!(r.placeholder.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_behavior_false_not_allowed() {
|
||||
let r = PrepareRenameResponse::from_lsp_value(&json!({ "defaultBehavior": false }));
|
||||
assert!(!r.allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_shape_not_allowed() {
|
||||
let r = PrepareRenameResponse::from_lsp_value(&json!({ "weird": 1 }));
|
||||
assert!(!r.allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_set_get_clear() {
|
||||
let mut s = PrepareRenameStore::new();
|
||||
let key = PrepareRenameKey::new("1", "file:///a");
|
||||
s.set(
|
||||
key.clone(),
|
||||
PrepareRenameResponse {
|
||||
allowed: true,
|
||||
placeholder: Some("x".into()),
|
||||
range: Some((0, 0, 0, 1)),
|
||||
},
|
||||
);
|
||||
assert!(s.get(&key).unwrap().allowed);
|
||||
s.clear(&key);
|
||||
assert!(s.get(&key).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -4087,6 +4087,195 @@ fn m4_21_semantic_tokens_full_then_delta() {
|
|||
assert_eq!(third_delta, vec![3, 0, 9, 1, 0]);
|
||||
}
|
||||
|
||||
/// T M4.5 — rename with `textDocument/prepareRename`. The `prepare`
|
||||
/// fake advertises `renameProvider.prepareProvider` and answers
|
||||
/// prepareRename with a `{ range, placeholder }`. `pmacs.lsp.rename`
|
||||
/// must do the prepare round-trip *before* the prompt opens (so the
|
||||
/// minibuffer isn't active synchronously), pre-fill the placeholder,
|
||||
/// then apply the rename on accept.
|
||||
#[test]
|
||||
fn m4_22_rename_prepare_gates_and_prefills() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let a_path = dir.path().join("a.rs");
|
||||
std::fs::write(&a_path, b"abcfooxyz\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 = 'prepare' }},
|
||||
}}"
|
||||
))
|
||||
.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.rename()")
|
||||
.exec()
|
||||
.expect("invoke rename");
|
||||
|
||||
// With prepareRename, the prompt is NOT open synchronously — it
|
||||
// opens only after the async prepare round-trip resolves.
|
||||
assert!(
|
||||
!state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.minibuffer.is_active()")
|
||||
.eval::<bool>()
|
||||
.unwrap(),
|
||||
"prompt must wait for the prepareRename round-trip"
|
||||
);
|
||||
assert!(
|
||||
pump_lua_flag(&mut state, "pmacs.minibuffer.is_active()", 5),
|
||||
"prepareRename allowed → prompt should have opened"
|
||||
);
|
||||
|
||||
// Placeholder pre-filled from the server's prepare response.
|
||||
let initial: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.minibuffer.contents()")
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
initial, "foo",
|
||||
"prompt should be pre-filled with the placeholder"
|
||||
);
|
||||
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.minibuffer.set_contents('BAR'); pmacs.minibuffer.accept()")
|
||||
.exec()
|
||||
.expect("accept rename");
|
||||
assert!(
|
||||
pump_lua_flag(
|
||||
&mut state,
|
||||
"(function() local b = pmacs.window.buffer() \
|
||||
return b ~= nil and b:slice(0, b:len()):find('BAR', 1, true) ~= nil end)()",
|
||||
5,
|
||||
),
|
||||
"rename never applied after prepare"
|
||||
);
|
||||
let a_text: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("local b = pmacs.window.buffer() return b:slice(0, b:len())")
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert_eq!(a_text, "abcBARxyz\n");
|
||||
}
|
||||
|
||||
/// T M4.5 — prepareRename refusal. The `preprefuse` fake answers
|
||||
/// prepareRename with `null`; `pmacs.lsp.rename` must abort without
|
||||
/// ever opening a prompt and leave the buffer untouched.
|
||||
#[test]
|
||||
fn m4_23_rename_prepare_refusal_aborts() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let a_path = dir.path().join("a.rs");
|
||||
std::fs::write(&a_path, b"abcfooxyz\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 = 'preprefuse' }},
|
||||
}}"
|
||||
))
|
||||
.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.rename()")
|
||||
.exec()
|
||||
.expect("invoke rename");
|
||||
|
||||
// Wait until the refusal actually landed (allowed == false), so
|
||||
// we're asserting after the abort path ran, not before.
|
||||
let refused = format!(
|
||||
"(function() \
|
||||
local sid \
|
||||
for _,r in ipairs(pmacs.lsp.list()) do \
|
||||
if r.state and r.state.kind=='initialized' then sid=r.id end \
|
||||
end \
|
||||
if not sid then return false end \
|
||||
local pr = pmacs.prepare_rename.result(sid, 'file://{a_disp}') \
|
||||
return pr ~= nil and pr.allowed == false \
|
||||
end)()"
|
||||
);
|
||||
assert!(
|
||||
pump_lua_flag(&mut state, &refused, 5),
|
||||
"prepareRename refusal never landed"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.minibuffer.is_active()")
|
||||
.eval::<bool>()
|
||||
.unwrap(),
|
||||
"a refused prepareRename must not open the prompt"
|
||||
);
|
||||
let a_text: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("local b = pmacs.window.buffer() return b:slice(0, b:len())")
|
||||
.eval()
|
||||
.unwrap();
|
||||
assert_eq!(a_text, "abcfooxyz\n", "buffer must be untouched");
|
||||
}
|
||||
|
||||
/// 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