///|
/// JS-target backend: Node.js `fs.watch` per root with `recursive: true`.
/// Event flow:
/// fs.watch listener pushes absolute paths onto a queue carried by
/// the opaque JS handle. `drain` pops the queue back into MoonBit
/// as a newline-separated string; each path then runs through
/// `classify_event` so callers see the same Created / Modified /
/// Removed semantics as on native.
///|
#external
priv type JsWatcherHandle
///|
extern "js" fn js_watch_start_ffi(roots_joined_lf : String) -> JsWatcherHandle =
#| (rootsJoinedLf) => {
#| const fs = require('node:fs');
#| const handle = { watchers: [], queue: [], closed: false };
#| const parts = rootsJoinedLf.split('\n').filter(Boolean);
#| for (const root of parts) {
#| try {
#| const w = fs.watch(root, { recursive: true, persistent: true }, (eventType, filename) => {
#| if (handle.closed) return;
#| if (!filename) return;
#| // Normalize backslash to slash so callers see the same separators
#| // they passed in (Node on Windows can emit either).
#| const rel = String(filename).replace(/\\/g, '/');
#| const full = root.replace(/[\\/]+$/, '') + '/' + rel;
#| handle.queue.push(full);
#| });
#| w.on('error', () => {});
#| handle.watchers.push(w);
#| } catch (_) {
#| /* skip unreadable roots; native backends do the same */
#| }
#| }
#| return handle;
#| }
///|
extern "js" fn js_watch_drain_ffi(handle : JsWatcherHandle) -> String =
#| (handle) => {
#| if (!handle || handle.closed) return '';
#| if (handle.queue.length === 0) return '';
#| const out = handle.queue.join('\n');
#| handle.queue.length = 0;
#| return out;
#| }
///|
extern "js" fn js_watch_close_ffi(handle : JsWatcherHandle) -> Unit =
#| (handle) => {
#| if (!handle || handle.closed) return;
#| handle.closed = true;
#| for (const w of handle.watchers) {
#| try { w.close(); } catch (_) {}
#| }
#| handle.watchers.length = 0;
#| handle.queue.length = 0;
#| }
///|
priv struct JsState {
handle : JsWatcherHandle
interval_ms : Int
norm : NormalizeState
}
///|
/// A filesystem watcher backed by Node's `fs.watch`. The public surface
/// mirrors the native build: `start` / `Watcher::next` / `Watcher::close`
/// / `Event` / `EventKind`.
pub struct Watcher {
priv state : JsState
priv mut closed : Bool
}
///|
fn default_exclude(_ : String) -> Bool {
false
}
///|
/// Start a watcher rooted at `roots`. `interval_ms` controls how often
/// `next` re-checks the JS-side queue (default 50 ms). `exclude` is
/// applied per path; returning `true` drops it. File paths that contain a
/// literal newline are not supported on this backend (Node's `fs.watch`
/// emits them through a JS string, which we deliberately split on newline
/// for the FFI boundary).
pub async fn start(
roots : Array[String],
interval_ms? : Int,
exclude? : (String) -> Bool,
) -> Watcher {
let ex = exclude.unwrap_or(default_exclude)
let interval = interval_ms.unwrap_or(50)
let joined = StringBuilder::new()
let mut first = true
for root in roots {
if !first {
joined.write_char('\n')
}
joined.write_string(root)
first = false
}
let handle = js_watch_start_ffi(joined.to_string())
let norm = build_normalize_state(roots, ex)
Watcher::{
state: JsState::{ handle, interval_ms: interval, norm },
closed: false,
}
}
///|
/// Aliases `start` on JS. Native exposes a forced-polling variant; on JS
/// there's no native backend to bypass, so `start_polling` is the same
/// thing. Kept for cross-target call-site compatibility.
pub async fn start_polling(
roots : Array[String],
interval_ms? : Int,
exclude? : (String) -> Bool,
) -> Watcher {
start(roots, interval_ms?, exclude?)
}
///|
/// Block until at least one event is observed (or the watcher is closed),
/// then return all events from that scan. Returns `[]` only on close.
pub async fn Watcher::next(self : Watcher) -> Array[Event] {
for ;; {
if self.closed {
break []
}
@async.sleep(self.state.interval_ms)
if self.closed {
break []
}
let raw = js_watch_drain_ffi(self.state.handle)
if raw.length() == 0 {
continue
}
let events : Array[Event] = []
for chunk in raw.split("\n") {
let raw_path = chunk.to_owned()
if raw_path.length() == 0 {
continue
}
match classify_event(self.state.norm, raw_path) {
Some(e) => events.push(e)
None => ()
}
}
if events.length() > 0 {
break events
}
}
}
///|
/// Mark the watcher closed; closes each underlying fs.watch handle.
/// Idempotent.
pub fn Watcher::close(self : Watcher) -> Unit {
if self.closed {
return
}
self.closed = true
js_watch_close_ffi(self.state.handle)
}