T M4.5: server→client refresh requests (inlayHint + semanticTokens)

Backlog item 1, combined (1a+1b) now that semantic tokens (#23) is
on main. Lets servers tell us cached inlay hints / semantic tokens
are stale and have the client re-pull, instead of the on-demand-
only v1 model.

- src/lsp.rs: advertise workspace.inlayHint.refreshSupport=true and
  workspace.semanticTokens.refreshSupport=true.
- builtin/runtime/lsp.lua: generalize the L3 workspace/applyEdit
  pump into handle_server_requests; add branches for
  workspace/inlayHint/refresh and workspace/semanticTokens/refresh
  — reply null per spec, then repull_for_attachments re-issues the
  matching request (request_inlay_hint / request_semantic_tokens)
  for every attached document on that server. Fire-and-forget; the
  response absorbs via its existing route like the command path.
  Only attachment servers are drained (directly-spawned test
  servers untouched).
- pmacs_fake_lsp.rs: `inlayrefresh` / `semantictokensrefresh`
  modes send the respective server→client refresh request at
  `initialized` (mirrors the wsconfig pattern).
- tests/m4_acceptance.rs: m4_18 / m4_19 attach via config and
  assert the store populates purely from the server-driven refresh
  chain — no explicit inlay_hints()/semantic_tokens() call.

Gates: lib 1285/0, m4 74/0, m8_1 10/0, m8_9 26/0, m8_10 19/0,
m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy
clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-19 13:36:23 -04:00
parent cf1c4c6ddc
commit 7ee1db5028
4 changed files with 182 additions and 12 deletions

View File

@ -16,8 +16,10 @@
-- `workspace/applyEdit` (L3), ordered resource-op edits
-- (create/rename/delete file) with buffer-registry reconciliation
-- (L4), inlay hints, and semantic tokens (each data + modeline;
-- wiring them into rendering is a separate rendering milestone).
-- File-watch capability registration is a later layer.
-- wiring them into rendering is a separate rendering milestone),
-- incl. the server→client `workspace/inlayHint/refresh` and
-- `workspace/semanticTokens/refresh` requests. File-watch
-- capability registration is a later layer.
pmacs.lsp = pmacs.lsp or {}
pmacs.lsp.config = pmacs.lsp.config or {}
@ -431,22 +433,42 @@ local function apply_workspace_edit(ops)
return edit_total, files, res_ops
end
-- T M4.5 L3 — server→client `workspace/applyEdit` pump.
-- Re-pull a per-`(server, uri)` store for every buffer attached to
-- `sid`. Fire-and-forget: the response absorbs into its store via the
-- request's route, exactly like the explicit command path — no await
-- needed. `request_fn(sid, uri)` issues the re-pull.
local function repull_for_attachments(sid, request_fn)
for _, rec in pairs(attachments) do
if rec.server == sid and rec.uri then
pcall(request_fn, sid, rec.uri)
end
end
end
-- T M4.5 — server→client request 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 }`.
-- Some server→client *requests* are 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 a
-- built-in answer). Each async tick we drain attachment servers'
-- events and handle:
--
-- * `workspace/applyEdit` (L3) — apply the edit through the shared
-- applier, reply `{ applied }`. After a code action's
-- `executeCommand`, servers (rust-analyzer, gopls, …) deliver the
-- actual change this way.
-- * `workspace/inlayHint/refresh` /
-- `workspace/semanticTokens/refresh` — the server signals its
-- cached hints/tokens are stale; reply `null` and re-pull that
-- family for every attached document so the matching store
-- (`pmacs.inlay_hint` / `pmacs.semantic_tokens`) stays fresh.
--
-- 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 function handle_server_requests()
local sids, seen = {}, {}
for _, rec in pairs(attachments) do
local sid = rec.server
@ -475,6 +497,18 @@ local function handle_apply_edit_requests()
local result = { applied = applied }
if not applied then result.failureReason = tostring(reason) end
pcall(pmacs.lsp.send_response, sid, ev.request_id, result)
elseif ev.kind == "request"
and ev.method == "workspace/inlayHint/refresh" then
-- Result is `null` on success per the LSP spec; then
-- re-pull so the store reflects the server's new state.
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
repull_for_attachments(sid, function(s, uri)
pmacs.lsp.request_inlay_hint(s, uri, 0, 0, 0xFFFFF, 0)
end)
elseif ev.kind == "request"
and ev.method == "workspace/semanticTokens/refresh" then
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
repull_for_attachments(sid, pmacs.lsp.request_semantic_tokens)
end
end
end
@ -485,7 +519,7 @@ 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)
pcall(handle_server_requests)
return ret
end
end

View File

@ -159,6 +159,29 @@ fn main() {
});
write_frame(&mut stdout, &req);
}
// T M4.5 `inlayrefresh` / `semantictokensrefresh`: right
// after initialize, signal that cached inlay hints /
// semantic tokens are stale via the matching server→client
// refresh request. The client must answer (null) and
// re-pull the corresponding `textDocument/*`.
("initialized", _) if mode == "inlayrefresh" => {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": 9200,
"method": "workspace/inlayHint/refresh",
"params": serde_json::Value::Null
});
write_frame(&mut stdout, &req);
}
("initialized", _) if mode == "semantictokensrefresh" => {
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": 9201,
"method": "workspace/semanticTokens/refresh",
"params": serde_json::Value::Null
});
write_frame(&mut stdout, &req);
}
("initialized", _) => {}
("shutdown", Some(idv)) => {
let resp = serde_json::json!({

View File

@ -2926,6 +2926,14 @@ fn default_capabilities() -> Value {
"configuration": true,
"workspaceFolders": true,
"didChangeConfiguration": { "dynamicRegistration": false },
// T M4.5 — let servers tell us cached inlay hints /
// semantic tokens are stale via a server→client
// `workspace/inlayHint/refresh` /
// `workspace/semanticTokens/refresh` request. The Lua
// server-request pump answers each and re-pulls the
// affected documents into the matching store.
"inlayHint": { "refreshSupport": true },
"semanticTokens": { "refreshSupport": true },
},
"textDocument": {
"synchronization": {

View File

@ -3868,6 +3868,111 @@ fn m4_17_lua_surface_drives_semantic_tokens() {
assert_eq!(type0_name, "namespace");
}
/// T M4.5 — server-driven inlay-hint refresh. The `inlayrefresh`
/// fake sends a `workspace/inlayHint/refresh` request right after
/// `initialized`. The bundle's server-request pump must answer it
/// and *re-pull* inlay hints for the attached document — so the
/// `pmacs.inlay_hint` store populates without anyone ever calling
/// `pmacs.lsp.inlay_hints()`.
#[test]
fn m4_18_inlay_hint_refresh_repulls_via_server_request() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let a_path = dir.path().join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n\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 = 'inlayrefresh' }},
}}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
// No explicit `pmacs.lsp.inlay_hints()` call: the store filling
// is driven purely by the server's refresh request.
let flag = 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 h = pmacs.inlay_hint.hints(sid, 'file://{a_disp}') \
return h ~= nil and #h > 0 \
end)()"
);
assert!(
pump_lua_flag(&mut state, &flag, 5),
"server-driven inlayHint/refresh never re-pulled hints into the store"
);
}
/// T M4.5 — server-driven semantic-tokens refresh. The
/// `semantictokensrefresh` fake sends `workspace/semanticTokens/
/// refresh` right after `initialized`; the pump must answer it and
/// re-pull, so `pmacs.semantic_tokens` populates with no explicit
/// `pmacs.lsp.semantic_tokens()` call.
#[test]
fn m4_19_semantic_tokens_refresh_repulls_via_server_request() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let a_path = dir.path().join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n\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 = 'semantictokensrefresh' }},
}}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
let flag = 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 t = pmacs.semantic_tokens.tokens(sid, 'file://{a_disp}') \
return t ~= nil and #t > 0 \
end)()"
);
assert!(
pump_lua_flag(&mut state, &flag, 5),
"server-driven semanticTokens/refresh never re-pulled tokens into the store"
);
}
/// 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