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:
Levi Neuwirth 2026-05-19 14:44:30 -04:00
parent 64427befac
commit 1c257305a4
5 changed files with 515 additions and 2 deletions

View File

@ -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

View File

@ -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",

View File

@ -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` /

View File

@ -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

View File

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