diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index c098eed..c734888 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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 diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index cc3bd90..b3a176a 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -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!({ diff --git a/src/lsp.rs b/src/lsp.rs index fd8e72c..e87e17f 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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": { diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 6d06f03..ff4b5a4 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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