diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 6c3e9cc..e994a72 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -19,8 +19,9 @@ -- (L4), inlay hints, and semantic tokens (each data + modeline; -- 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. +-- `workspace/semanticTokens/refresh` requests, and dynamic +-- `workspace/didChangeWatchedFiles` registration backed by a +-- polling snapshot-diff watcher. pmacs.lsp = pmacs.lsp or {} pmacs.lsp.config = pmacs.lsp.config or {} @@ -451,6 +452,244 @@ local function repull_for_attachments(sid, request_fn) 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. -- -- 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 -- family for every attached document so the matching store -- (`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) -- 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 pcall(pmacs.lsp.send_response, sid, ev.request_id, nil) 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 diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 1593d29..4d088e2 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -189,7 +189,58 @@ fn main() { }); 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", _) => {} + // T M4.5: the client's file-watch notifications. Append + // `type uri` lines to `/.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)) => { let resp = serde_json::json!({ "jsonrpc": "2.0", diff --git a/src/lsp.rs b/src/lsp.rs index d173496..4b6bbce 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -963,6 +963,23 @@ struct PendingExternal { 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, +} + impl LspManager { /// Construct a fresh manager wired to `supervisor` and the /// editor's `runtime`. The runtime bridges the supervisor's @@ -1093,6 +1110,43 @@ impl LspManager { 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 { + 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 /// stream. The modeline reads its label from this. #[must_use] @@ -2972,6 +3026,22 @@ impl LspManager { 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`. pub fn did_close(&mut self, sid: LspServerId, uri: impl Into) -> Result<(), String> { let uri = uri.into(); @@ -3101,6 +3171,14 @@ fn default_capabilities() -> Value { "configuration": true, "workspaceFolders": true, "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 / // semantic tokens are stale via a server→client // `workspace/inlayHint/refresh` / diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 83b9010..a8cf484 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -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 // init.lua (e.g. raise it for a slow language server on a diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index a21a2ea..708ee59 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -4276,6 +4276,94 @@ fn m4_23_rename_prepare_refusal_aborts() { 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 +/// `/.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 /// hooks are defined, the namespace tables exist, the user-facing /// 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) /// and pump until the manager reports it `initialized`. fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str>) {