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>) {