T M4.5: workspace/didChangeWatchedFiles (backlog item 4 — closes backlog)
Dynamic file-watch registration with a full snapshot-diff watcher. - src/lsp.rs: did_change_watched_files(sid, &changes) notification; capability workspace.didChangeWatchedFiles.dynamicRegistration=true (mandatory — clangd/rust-analyzer/gopls only register dynamically). - src/lua_bindings.rs: pmacs.lsp.did_change_watched_files binding. - builtin/runtime/lsp.lua: client/(un)registerCapability handled in the server-request pump (reply null; start/stop watchers). Brace- expanding glob → anchored Lua pattern; recursive read_dir/stat snapshot-diff poller emitting per-file created/changed/deleted filtered by glob + WatchKind, batched into one notification; self-cancels when the server dies or unregisters. luajit-safe (kind_has() arithmetic, no 5.3 bitwise). - pmacs_fake_lsp.rs: `filewatch` mode registers a **/*.txt watcher and logs received changes to <base>/.received (disk side-channel — the protocol stream is drained by the pump). - tests/m4_acceptance.rs: m4_24 asserts create(1)/change(2)/ delete(3) for matching .txt only; non-matching .md filtered. Bug caught in validation: `**/` → `(.*/)?` is not a valid Lua pattern (no group quantifier) — matched nothing, zero events. Fixed to `**/`→`.-`, `**`→`.*`; m4_24 surfaced it. client/unregisterCapability cancels watcher records (code-reviewed); not asserted in m4_24 — a "no further notifications" negative-timing check is flaky; the create/change/delete + filter path is the deterministic proof. Gates: lib 1301/0, m4 79/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:
parent
64427befac
commit
1c257305a4
|
|
@ -19,8 +19,9 @@
|
||||||
-- (L4), inlay hints, and semantic tokens (each data + modeline;
|
-- (L4), inlay hints, and semantic tokens (each data + modeline;
|
||||||
-- wiring them into rendering is a separate rendering milestone),
|
-- wiring them into rendering is a separate rendering milestone),
|
||||||
-- incl. the server→client `workspace/inlayHint/refresh` and
|
-- incl. the server→client `workspace/inlayHint/refresh` and
|
||||||
-- `workspace/semanticTokens/refresh` requests. File-watch
|
-- `workspace/semanticTokens/refresh` requests, and dynamic
|
||||||
-- capability registration is a later layer.
|
-- `workspace/didChangeWatchedFiles` registration backed by a
|
||||||
|
-- polling snapshot-diff watcher.
|
||||||
|
|
||||||
pmacs.lsp = pmacs.lsp or {}
|
pmacs.lsp = pmacs.lsp or {}
|
||||||
pmacs.lsp.config = pmacs.lsp.config or {}
|
pmacs.lsp.config = pmacs.lsp.config or {}
|
||||||
|
|
@ -451,6 +452,244 @@ local function repull_for_attachments(sid, request_fn)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- T M4.5 — workspace file watching (workspace/didChangeWatchedFiles).
|
||||||
|
--
|
||||||
|
-- Servers register watchers dynamically via client/registerCapability.
|
||||||
|
-- pmacs has no kernel file-watch, so each registration runs a polling
|
||||||
|
-- snapshot-diff coroutine: walk the base dir into a { relpath = sig }
|
||||||
|
-- map and, every tick, diff against the previous map to emit per-file
|
||||||
|
-- created/changed/deleted FileEvents (filtered by the glob and the
|
||||||
|
-- WatchKind bitmask), batched into one notification. Coarser than an
|
||||||
|
-- inotify bridge but accurate; a watcher self-cancels when the server
|
||||||
|
-- dies or the capability is unregistered.
|
||||||
|
|
||||||
|
local FILE_WATCH_INTERVAL_MS = 250
|
||||||
|
|
||||||
|
-- file_watchers[tostring(sid)][registrationId] = list of watch records
|
||||||
|
-- ({ cancelled = bool, _sleep = handle? }), one per glob watcher.
|
||||||
|
local file_watchers = {}
|
||||||
|
|
||||||
|
-- WatchKind is a bitmask (Create=1, Change=2, Delete=4); test it
|
||||||
|
-- arithmetically so this stays valid under luajit (no 5.3 `&`).
|
||||||
|
local function kind_has(mask, bit)
|
||||||
|
return mask % (bit * 2) >= bit
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Expand `{a,b}` alternations into brace-free globs (nested handled
|
||||||
|
-- by recursing the remainder; unbalanced braces left literal).
|
||||||
|
local function expand_braces(glob)
|
||||||
|
local open = glob:find("{", 1, true)
|
||||||
|
if not open then return { glob } end
|
||||||
|
local depth, close = 0, nil
|
||||||
|
for i = open, #glob do
|
||||||
|
local c = glob:sub(i, i)
|
||||||
|
if c == "{" then
|
||||||
|
depth = depth + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
depth = depth - 1
|
||||||
|
if depth == 0 then
|
||||||
|
close = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not close then return { glob } end
|
||||||
|
local prefix, body, suffix =
|
||||||
|
glob:sub(1, open - 1), glob:sub(open + 1, close - 1), glob:sub(close + 1)
|
||||||
|
local parts, d2, start = {}, 0, 1
|
||||||
|
for i = 1, #body do
|
||||||
|
local c = body:sub(i, i)
|
||||||
|
if c == "{" then
|
||||||
|
d2 = d2 + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
d2 = d2 - 1
|
||||||
|
elseif c == "," and d2 == 0 then
|
||||||
|
parts[#parts + 1] = body:sub(start, i - 1)
|
||||||
|
start = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = body:sub(start)
|
||||||
|
local out = {}
|
||||||
|
for _, alt in ipairs(parts) do
|
||||||
|
for _, tail in ipairs(expand_braces(suffix)) do
|
||||||
|
out[#out + 1] = prefix .. alt .. tail
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Translate one brace-free glob to an anchored Lua pattern.
|
||||||
|
local function glob_one_to_pattern(glob)
|
||||||
|
local p, i, n = "^", 1, #glob
|
||||||
|
while i <= n do
|
||||||
|
local c = glob:sub(i, i)
|
||||||
|
if c == "*" then
|
||||||
|
if glob:sub(i + 1, i + 1) == "*" then
|
||||||
|
-- Lua patterns can't quantify a group, so `**/` (zero+ path
|
||||||
|
-- segments) becomes the lazy `.-` (`.` spans `/`); a bare
|
||||||
|
-- `**` becomes `.*`.
|
||||||
|
if glob:sub(i + 2, i + 2) == "/" then
|
||||||
|
p, i = p .. ".-", i + 3
|
||||||
|
else
|
||||||
|
p, i = p .. ".*", i + 2
|
||||||
|
end
|
||||||
|
else
|
||||||
|
p, i = p .. "[^/]*", i + 1
|
||||||
|
end
|
||||||
|
elseif c == "?" then
|
||||||
|
p, i = p .. "[^/]", i + 1
|
||||||
|
elseif c == "[" then
|
||||||
|
local j = i + 1
|
||||||
|
if glob:sub(j, j) == "!" then j = j + 1 end
|
||||||
|
if glob:sub(j, j) == "]" then j = j + 1 end
|
||||||
|
while j <= n and glob:sub(j, j) ~= "]" do j = j + 1 end
|
||||||
|
local cls = glob:sub(i + 1, j - 1):gsub("^!", "^")
|
||||||
|
p, i = p .. "[" .. cls .. "]", j + 1
|
||||||
|
else
|
||||||
|
if c:match("[%(%)%.%%%+%-%^%$%[%]%*%?]") then
|
||||||
|
p = p .. "%" .. c
|
||||||
|
else
|
||||||
|
p = p .. c
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return p .. "$"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function glob_matcher(glob)
|
||||||
|
local pats = {}
|
||||||
|
for _, g in ipairs(expand_braces(glob)) do
|
||||||
|
pats[#pats + 1] = glob_one_to_pattern(g)
|
||||||
|
end
|
||||||
|
return function(rel)
|
||||||
|
for _, pat in ipairs(pats) do
|
||||||
|
if rel:match(pat) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Recursively list files under `base` → { relpath = sig }. `sig`
|
||||||
|
-- folds size+mtime+kind so a content/metadata change flips it.
|
||||||
|
-- Symlinks are recorded, not traversed (loop-safe). Awaits fs
|
||||||
|
-- primitives, so call from inside an async coroutine.
|
||||||
|
local function scan_tree(base, matches)
|
||||||
|
local out = {}
|
||||||
|
local function walk(dir, rel_prefix)
|
||||||
|
local ok, entries = pcall(function()
|
||||||
|
return pmacs.fs.read_dir(dir):await()
|
||||||
|
end)
|
||||||
|
if not ok or not entries then return end
|
||||||
|
for _, e in ipairs(entries) do
|
||||||
|
local rel = (rel_prefix == "") and e.name or (rel_prefix .. "/" .. e.name)
|
||||||
|
if e.kind == "dir" then
|
||||||
|
walk(dir .. "/" .. e.name, rel)
|
||||||
|
elseif matches(rel) then
|
||||||
|
out[rel] = table.concat({
|
||||||
|
tostring(e.size), tostring(e.mtime),
|
||||||
|
tostring(e.mtime_nsec), tostring(e.kind),
|
||||||
|
}, "|")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
walk(base, "")
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local FC_CREATED, FC_CHANGED, FC_DELETED = 1, 2, 3
|
||||||
|
|
||||||
|
local function start_file_watcher(sid, base, glob, kind_mask, record)
|
||||||
|
local matches = glob_matcher(glob)
|
||||||
|
pmacs.async(function()
|
||||||
|
local prev = scan_tree(base, matches)
|
||||||
|
while not record.cancelled and server_is_live(sid) do
|
||||||
|
local sh = pmacs.workers.sleep(FILE_WATCH_INTERVAL_MS)
|
||||||
|
record._sleep = sh
|
||||||
|
pcall(function() sh:await() end)
|
||||||
|
record._sleep = nil
|
||||||
|
if record.cancelled or not server_is_live(sid) then break end
|
||||||
|
|
||||||
|
local cur = scan_tree(base, matches)
|
||||||
|
local changes = {}
|
||||||
|
for rel, sig in pairs(cur) do
|
||||||
|
local was = prev[rel]
|
||||||
|
if was == nil then
|
||||||
|
if kind_has(kind_mask, 1) then
|
||||||
|
changes[#changes + 1] =
|
||||||
|
{ uri = file_uri_for(base .. "/" .. rel), type = FC_CREATED }
|
||||||
|
end
|
||||||
|
elseif was ~= sig and kind_has(kind_mask, 2) then
|
||||||
|
changes[#changes + 1] =
|
||||||
|
{ uri = file_uri_for(base .. "/" .. rel), type = FC_CHANGED }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for rel in pairs(prev) do
|
||||||
|
if cur[rel] == nil and kind_has(kind_mask, 4) then
|
||||||
|
changes[#changes + 1] =
|
||||||
|
{ uri = file_uri_for(base .. "/" .. rel), type = FC_DELETED }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #changes > 0 then
|
||||||
|
pcall(pmacs.lsp.did_change_watched_files, sid, changes)
|
||||||
|
end
|
||||||
|
prev = cur
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve a GlobPattern (string | { baseUri, pattern }) to
|
||||||
|
-- (base_dir, pattern). A bare string with no base falls back to the
|
||||||
|
-- directory of an attached file on `sid` (best effort).
|
||||||
|
local function resolve_watcher(sid, gp)
|
||||||
|
if type(gp) == "table" and gp.baseUri then
|
||||||
|
return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**"
|
||||||
|
end
|
||||||
|
if type(gp) == "string" then
|
||||||
|
for _, rec in pairs(attachments) do
|
||||||
|
if rec.server == sid and rec.uri then
|
||||||
|
local p = pmacs.lsp.path_for_uri(rec.uri)
|
||||||
|
local dir = p and p:match("^(.*)/[^/]*$")
|
||||||
|
if dir then return dir, gp end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_file_watchers(sid, registrations)
|
||||||
|
local skey = tostring(sid)
|
||||||
|
file_watchers[skey] = file_watchers[skey] or {}
|
||||||
|
for _, reg in ipairs(registrations or {}) do
|
||||||
|
if reg.method == "workspace/didChangeWatchedFiles" then
|
||||||
|
local recs = {}
|
||||||
|
for _, w in ipairs((reg.registerOptions or {}).watchers or {}) do
|
||||||
|
local base, pat = resolve_watcher(sid, w.globPattern)
|
||||||
|
if base and pat then
|
||||||
|
local r = { cancelled = false }
|
||||||
|
recs[#recs + 1] = r
|
||||||
|
start_file_watcher(sid, base, pat, w.kind or 7, r)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
file_watchers[skey][reg.id] = recs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unregister_file_watchers(sid, unregs)
|
||||||
|
local byid = file_watchers[tostring(sid)]
|
||||||
|
if not byid then return end
|
||||||
|
for _, u in ipairs(unregs or {}) do
|
||||||
|
if u.method == "workspace/didChangeWatchedFiles" and byid[u.id] then
|
||||||
|
for _, r in ipairs(byid[u.id]) do
|
||||||
|
r.cancelled = true
|
||||||
|
if r._sleep then pcall(function() r._sleep:cancel() end) end
|
||||||
|
end
|
||||||
|
byid[u.id] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- T M4.5 — server→client request pump.
|
-- T M4.5 — server→client request pump.
|
||||||
--
|
--
|
||||||
-- Some server→client *requests* are surfaced by the manager as a
|
-- Some server→client *requests* are surfaced by the manager as a
|
||||||
|
|
@ -468,6 +707,9 @@ end
|
||||||
-- cached hints/tokens are stale; reply `null` and re-pull that
|
-- cached hints/tokens are stale; reply `null` and re-pull that
|
||||||
-- family for every attached document so the matching store
|
-- family for every attached document so the matching store
|
||||||
-- (`pmacs.inlay_hint` / `pmacs.semantic_tokens`) stays fresh.
|
-- (`pmacs.inlay_hint` / `pmacs.semantic_tokens`) stays fresh.
|
||||||
|
-- * `client/registerCapability` / `client/unregisterCapability` —
|
||||||
|
-- start/stop the file-watch coroutines for any
|
||||||
|
-- `workspace/didChangeWatchedFiles` registration; reply `null`.
|
||||||
--
|
--
|
||||||
-- Only servers in `attachments` are drained, so a test (or package)
|
-- Only servers in `attachments` are drained, so a test (or package)
|
||||||
-- that owns its own directly-spawned server and reads its events
|
-- that owns its own directly-spawned server and reads its events
|
||||||
|
|
@ -515,6 +757,17 @@ local function handle_server_requests()
|
||||||
and ev.method == "workspace/semanticTokens/refresh" then
|
and ev.method == "workspace/semanticTokens/refresh" then
|
||||||
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
|
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
|
||||||
repull_for_attachments(sid, pmacs.lsp.request_semantic_tokens)
|
repull_for_attachments(sid, pmacs.lsp.request_semantic_tokens)
|
||||||
|
elseif ev.kind == "request"
|
||||||
|
and ev.method == "client/registerCapability" then
|
||||||
|
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
|
||||||
|
pcall(register_file_watchers, sid,
|
||||||
|
ev.params and ev.params.registrations)
|
||||||
|
elseif ev.kind == "request"
|
||||||
|
and ev.method == "client/unregisterCapability" then
|
||||||
|
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
|
||||||
|
-- LSP spells the field "unregisterations".
|
||||||
|
pcall(unregister_file_watchers, sid,
|
||||||
|
ev.params and ev.params.unregisterations)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,58 @@ fn main() {
|
||||||
});
|
});
|
||||||
write_frame(&mut stdout, &req);
|
write_frame(&mut stdout, &req);
|
||||||
}
|
}
|
||||||
|
// T M4.5 `filewatch`: dynamically register a
|
||||||
|
// `workspace/didChangeWatchedFiles` watcher (RelativePattern
|
||||||
|
// rooted at PMACS_FAKE_LSP_WATCH_BASE, `**/*.txt`, all
|
||||||
|
// kinds). The client must reply null and start watching.
|
||||||
|
("initialized", _) if mode == "filewatch" => {
|
||||||
|
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
||||||
|
let req = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 9300,
|
||||||
|
"method": "client/registerCapability",
|
||||||
|
"params": { "registrations": [{
|
||||||
|
"id": "watch-1",
|
||||||
|
"method": "workspace/didChangeWatchedFiles",
|
||||||
|
"registerOptions": { "watchers": [{
|
||||||
|
"globPattern": {
|
||||||
|
"baseUri": format!("file://{base}"),
|
||||||
|
"pattern": "**/*.txt"
|
||||||
|
},
|
||||||
|
"kind": 7
|
||||||
|
}] }
|
||||||
|
}] }
|
||||||
|
});
|
||||||
|
write_frame(&mut stdout, &req);
|
||||||
|
}
|
||||||
("initialized", _) => {}
|
("initialized", _) => {}
|
||||||
|
// T M4.5: the client's file-watch notifications. Append
|
||||||
|
// `type uri` lines to `<base>/.received` as a test
|
||||||
|
// side-channel (the protocol stream is drained by the Lua
|
||||||
|
// server-request pump, so a disk channel is observable).
|
||||||
|
("workspace/didChangeWatchedFiles", _) => {
|
||||||
|
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
||||||
|
if !base.is_empty()
|
||||||
|
&& let Some(changes) = params.get("changes").and_then(|c| c.as_array())
|
||||||
|
&& let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(format!("{base}/.received"))
|
||||||
|
{
|
||||||
|
use std::io::Write as _;
|
||||||
|
for ch in changes {
|
||||||
|
let t = ch
|
||||||
|
.get("type")
|
||||||
|
.and_then(serde_json::Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let u = ch
|
||||||
|
.get("uri")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let _ = writeln!(f, "{t} {u}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
("shutdown", Some(idv)) => {
|
("shutdown", Some(idv)) => {
|
||||||
let resp = serde_json::json!({
|
let resp = serde_json::json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
|
|
|
||||||
78
src/lsp.rs
78
src/lsp.rs
|
|
@ -963,6 +963,23 @@ struct PendingExternal {
|
||||||
dispatched_at: Instant,
|
dispatched_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-server styling context for one URI's semantic tokens,
|
||||||
|
/// resolved by [`LspManager::semantic_style_context`]. Decouples the
|
||||||
|
/// semantic-render producer from `LspServerId` (kept opaque) while
|
||||||
|
/// still giving it everything it needs to turn raw tokens into
|
||||||
|
/// byte-anchored, named style spans.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SemanticStyleContext {
|
||||||
|
/// The owning server's negotiated position encoding. The
|
||||||
|
/// producer converts token `start`/`length` from these units to
|
||||||
|
/// pmacs byte offsets (a no-op when it is `Utf8`).
|
||||||
|
pub encoding: PositionEncoding,
|
||||||
|
/// The server's `semanticTokensProvider.legend`, if advertised.
|
||||||
|
/// `None` ⇒ the producer falls back to raw type indices (the
|
||||||
|
/// same degradation `lsp.lua`'s summary already tolerates).
|
||||||
|
pub legend: Option<crate::semantic_tokens::SemanticTokensLegend>,
|
||||||
|
}
|
||||||
|
|
||||||
impl LspManager {
|
impl LspManager {
|
||||||
/// Construct a fresh manager wired to `supervisor` and the
|
/// Construct a fresh manager wired to `supervisor` and the
|
||||||
/// editor's `runtime`. The runtime bridges the supervisor's
|
/// editor's `runtime`. The runtime bridges the supervisor's
|
||||||
|
|
@ -1093,6 +1110,43 @@ impl LspManager {
|
||||||
self.semantic_token_store.clone()
|
self.semantic_token_store.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Styling inputs for `uri`'s semantic tokens: the owning
|
||||||
|
/// server's negotiated [`PositionEncoding`] and its
|
||||||
|
/// `semanticTokensProvider` legend (if the server advertised
|
||||||
|
/// one). `None` when no attached server has tokens for `uri`.
|
||||||
|
///
|
||||||
|
/// Step 0 of the semantic-frontend producer arc established why
|
||||||
|
/// this is needed: `SemanticToken` `start`/`length` are LSP
|
||||||
|
/// encoding units (UTF-16 for clangd's default) and — unlike
|
||||||
|
/// inlay hints — are *not* byte-rewritten by the absorb path's
|
||||||
|
/// `inbound_converted`, because the relative-encoded `data`
|
||||||
|
/// array carries no `Position`-shaped object for the structural
|
||||||
|
/// walk to find. The producer therefore converts them itself and
|
||||||
|
/// needs the per-server encoding plus the legend to name token
|
||||||
|
/// types. The server resolved here is the same one
|
||||||
|
/// [`crate::semantic_tokens::SemanticTokenStore::for_uri`]
|
||||||
|
/// returns (lowest id), so a producer's token read and this
|
||||||
|
/// context read agree on the source.
|
||||||
|
#[must_use]
|
||||||
|
pub fn semantic_style_context(&self, uri: &str) -> Option<SemanticStyleContext> {
|
||||||
|
let server_key = {
|
||||||
|
let store = self.semantic_token_store.lock().ok()?;
|
||||||
|
store.for_uri(uri).map(|(s, _)| s.to_owned())?
|
||||||
|
};
|
||||||
|
let sid = self
|
||||||
|
.clients
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.find(|id| id.raw().to_string() == server_key)?;
|
||||||
|
let legend = self
|
||||||
|
.capabilities(sid)
|
||||||
|
.and_then(crate::semantic_tokens::SemanticTokensLegend::from_capabilities);
|
||||||
|
Some(SemanticStyleContext {
|
||||||
|
encoding: self.position_encoding(sid),
|
||||||
|
legend,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// T M4.8: per-server status snapshot, derived from the LSP event
|
/// T M4.8: per-server status snapshot, derived from the LSP event
|
||||||
/// stream. The modeline reads its label from this.
|
/// stream. The modeline reads its label from this.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -2972,6 +3026,22 @@ impl LspManager {
|
||||||
self.send_notification(sid, "textDocument/didChange", params)
|
self.send_notification(sid, "textDocument/didChange", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send `workspace/didChangeWatchedFiles` to `sid`. `changes` is
|
||||||
|
/// the already-shaped `FileEvent[]` array (`[{ uri, type }]`,
|
||||||
|
/// type 1=created / 2=changed / 3=deleted) the Lua file-watch
|
||||||
|
/// module builds. T M4.5.
|
||||||
|
pub fn did_change_watched_files(
|
||||||
|
&mut self,
|
||||||
|
sid: LspServerId,
|
||||||
|
changes: &Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.send_notification(
|
||||||
|
sid,
|
||||||
|
"workspace/didChangeWatchedFiles",
|
||||||
|
json!({ "changes": changes }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience: send `textDocument/didClose` to `sid`.
|
/// Convenience: send `textDocument/didClose` to `sid`.
|
||||||
pub fn did_close(&mut self, sid: LspServerId, uri: impl Into<String>) -> Result<(), String> {
|
pub fn did_close(&mut self, sid: LspServerId, uri: impl Into<String>) -> Result<(), String> {
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
|
|
@ -3101,6 +3171,14 @@ fn default_capabilities() -> Value {
|
||||||
"configuration": true,
|
"configuration": true,
|
||||||
"workspaceFolders": true,
|
"workspaceFolders": true,
|
||||||
"didChangeConfiguration": { "dynamicRegistration": false },
|
"didChangeConfiguration": { "dynamicRegistration": false },
|
||||||
|
// T M4.5 — file watching. `dynamicRegistration: true` is
|
||||||
|
// mandatory: clangd / rust-analyzer / gopls only ever
|
||||||
|
// register `workspace/didChangeWatchedFiles` dynamically
|
||||||
|
// (via `client/registerCapability`); without it the
|
||||||
|
// server never asks us to watch and the feature is dead.
|
||||||
|
// The Lua server-request pump handles the registration
|
||||||
|
// and runs the snapshot-diff watcher.
|
||||||
|
"didChangeWatchedFiles": { "dynamicRegistration": true },
|
||||||
// T M4.5 — let servers tell us cached inlay hints /
|
// T M4.5 — let servers tell us cached inlay hints /
|
||||||
// semantic tokens are stale via a server→client
|
// semantic tokens are stale via a server→client
|
||||||
// `workspace/inlayHint/refresh` /
|
// `workspace/inlayHint/refresh` /
|
||||||
|
|
|
||||||
|
|
@ -7270,6 +7270,23 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// T M4.5 file watching. `changes` is the Lua-built FileEvent
|
||||||
|
// array `{ { uri = , type = 1|2|3 }, … }`; converted to JSON
|
||||||
|
// and sent as `workspace/didChangeWatchedFiles`.
|
||||||
|
let m = manager.clone();
|
||||||
|
lsp_mod.set(
|
||||||
|
"did_change_watched_files",
|
||||||
|
lua.create_function(move |_, (id, changes): (LspServerIdLua, Value)| {
|
||||||
|
let changes = lua_to_json(changes)?;
|
||||||
|
m.borrow_mut()
|
||||||
|
.did_change_watched_files(id.0, &changes)
|
||||||
|
.map_err(mlua::Error::external)?;
|
||||||
|
Ok(())
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// T M4.5 task #8: hard per-request timeout, tunable from
|
// T M4.5 task #8: hard per-request timeout, tunable from
|
||||||
// init.lua (e.g. raise it for a slow language server on a
|
// init.lua (e.g. raise it for a slow language server on a
|
||||||
|
|
|
||||||
|
|
@ -4276,6 +4276,94 @@ fn m4_23_rename_prepare_refusal_aborts() {
|
||||||
assert_eq!(a_text, "abcfooxyz\n", "buffer must be untouched");
|
assert_eq!(a_text, "abcfooxyz\n", "buffer must be untouched");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// T M4.5 — dynamic `workspace/didChangeWatchedFiles`. The
|
||||||
|
/// `filewatch` fake registers (via `client/registerCapability`) a
|
||||||
|
/// `**/*.txt` watcher rooted at the tempdir. The bundle's
|
||||||
|
/// snapshot-diff watcher must report create/change/delete events
|
||||||
|
/// for matching files only; the fake logs received changes to
|
||||||
|
/// `<base>/.received` as a disk side-channel (the protocol stream is
|
||||||
|
/// drained by the server-request pump).
|
||||||
|
#[test]
|
||||||
|
fn m4_24_workspace_did_change_watched_files() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let base = dir.path().to_path_buf();
|
||||||
|
let base_disp = base.display().to_string();
|
||||||
|
let a_path = base.join("a.rs");
|
||||||
|
std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
|
||||||
|
let a_disp = a_path.display().to_string();
|
||||||
|
let received = base.join(".received");
|
||||||
|
let foo_uri = format!("file://{}", base.join("foo.txt").display());
|
||||||
|
|
||||||
|
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 = 'filewatch',
|
||||||
|
PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
|
||||||
|
))
|
||||||
|
.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"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let registerCapability be processed and the watcher establish
|
||||||
|
// an empty `.txt` baseline (≈3 poll intervals) before creating
|
||||||
|
// files, so the create is a CREATED event, not folded into the
|
||||||
|
// initial scan.
|
||||||
|
let warm = Instant::now() + Duration::from_millis(900);
|
||||||
|
while Instant::now() < warm {
|
||||||
|
state.tick_processes();
|
||||||
|
state.tick_lsp();
|
||||||
|
state.tick_async();
|
||||||
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
|
||||||
|
std::fs::write(base.join("bar.md"), b"md\n").expect("write bar.md");
|
||||||
|
assert!(
|
||||||
|
pump_until_file_contains(&mut state, &received, &format!("1 {foo_uri}"), 6),
|
||||||
|
"CREATED for foo.txt never reported; .received = {:?}",
|
||||||
|
std::fs::read_to_string(&received).unwrap_or_default()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!std::fs::read_to_string(&received)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("bar.md"),
|
||||||
|
"non-matching .md must be filtered out"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(base.join("foo.txt"), b"two two\n").expect("modify foo.txt");
|
||||||
|
assert!(
|
||||||
|
pump_until_file_contains(&mut state, &received, &format!("2 {foo_uri}"), 6),
|
||||||
|
"CHANGED for foo.txt never reported"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_file(base.join("foo.txt")).expect("delete foo.txt");
|
||||||
|
assert!(
|
||||||
|
pump_until_file_contains(&mut state, &received, &format!("3 {foo_uri}"), 6),
|
||||||
|
"DELETED for foo.txt never reported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the
|
/// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the
|
||||||
/// hooks are defined, the namespace tables exist, the user-facing
|
/// hooks are defined, the namespace tables exist, the user-facing
|
||||||
/// commands are registered with the command registry, and the default
|
/// commands are registered with the command registry, and the default
|
||||||
|
|
@ -4490,6 +4578,32 @@ fn pump_lua_flag(state: &mut pmacs::editor::EditorState, flag: &str, secs: u64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tick the full frame order until `path`'s contents contain
|
||||||
|
/// `needle`, or the deadline lapses.
|
||||||
|
fn pump_until_file_contains(
|
||||||
|
state: &mut pmacs::editor::EditorState,
|
||||||
|
path: &std::path::Path,
|
||||||
|
needle: &str,
|
||||||
|
secs: u64,
|
||||||
|
) -> bool {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
|
loop {
|
||||||
|
state.tick_processes();
|
||||||
|
state.tick_lsp();
|
||||||
|
state.tick_async();
|
||||||
|
if std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains(needle)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the fake LSP via the Lua surface (optionally in a test mode)
|
/// Spawn the fake LSP via the Lua surface (optionally in a test mode)
|
||||||
/// and pump until the manager reports it `initialized`.
|
/// and pump until the manager reports it `initialized`.
|
||||||
fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str>) {
|
fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str>) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue