Spice version 0.1.0 -- cross-platform filesystem watcher (Linux inotify, Darwin kqueue) with debounce + coalescing for CLI tools. Audience: Turmeric users writing a tool that needs to "react when a file changes" without re-inventing a watch loop per tool.
This guide walks the five things you'll do most often:
Each section is a self-contained snippet you can drop into a defn main.
In your project's build.tur:
:spices #{
"watch" #{:url "https://github.com/rjungemann/turmeric-spices"
:ref "main"
:subdir "spices/watch"}
}
Then run tur fetch. The spice has no external C dependencies; the
inotify / kqueue bridge lives inside watch/backend as inline-C.
The simplest pattern: open, block, classify, repeat. watch-next returns
an event handle (or 0 on timeout) and you free the handle when done.
(defmodule app/main
(import watch/event :refer [watch-event-kind watch-event-free
watch-kind->cstr])
(import watch/opts :refer [default-watch-opts])
(import watch/watch :refer [watch-open-one watch-close watch-next])
(defn main [] #{Unsafe} :int
(let [w (watch-open-one "notes.md" (default-watch-opts))]
(do
(rerun-on-change w)
(watch-close w)
0)))
(defn rerun-on-change [w :int] #{Unsafe} :int
(let [ev (watch-next w -1)] ; -1 = block forever
(if (= ev 0)
0
(do
(println (watch-kind->cstr (watch-event-kind ev)))
(watch-event-free ev)
(rerun-on-change w))))))
Pass a default-watch-opts handle (150 ms debounce window, coalesce on,
non-recursive) or build your own with watch-opts-make. Pass 0 for
opts and the watcher uses the same defaults internally.
Atomic-save is the happy path. Most editors save by writing a sibling
temp file and renaming it over the target. Both backends fire on the
rename and the watcher's stat-compare reports watch-kind-rename. In-place
writes also fire on Linux; on Darwin the kqueue-on-directory backend does
not see them (this is documented in docs/notebook-watch-semantics.md).
Pass a directory to watch-open-tree (or to watch-open with
opts.recursive = 1) and it walks the tree at startup, opening one
backend per subdir. Hidden entries (.git, .tur-cache, ...) and
symlinks are skipped to avoid cycles.
(let [opts (watch-opts-make 1 150 1 0 1)
w (watch-open-tree "src" opts)]
(do
(println-int (watch-tree-dir-count w)) ; how many dirs registered
(loop-forever w)
(watch-close w)
(watch-opts-free opts)))
watch-next on a tree watcher reports the directory that fired, not
the changed file -- the kqueue backend on Darwin doesn't expose per-file
names. Callers that need a filename should re-enumerate the dir and diff.
If a new subdirectory appears inside the tree after open, call
watch-refresh to register it. v0.1.0 does not auto-refresh on
watch-kind-create events.
See examples/watch-tree.tur for the full demo.
Bursty save flows (vim writing several backup files, or a build tool touching many files in one transaction) produce many backend events for one logical change. The default 150 ms debounce window absorbs the burst into a single emitted event.
For multi-path callers, use watch-drain:
(let [batch (watch-drain w 5000 150)] ; 5 s timeout, 150 ms window
(process-batch batch)) ; cons-list of event handles
v0.1.0 single-file watch always produces a 0- or 1-element batch
(watch-next already handles burst-collapse internally). Tree mode is
the same shape today; multi-element batches will arrive when path-level
classification lands.
If you need to dedupe paths inside a batch yourself, watch/debounce
exposes the primitive:
(let [b (debounce-batch-make)]
(debounce-batch-push b ev1)
(debounce-batch-push b ev2)
(debounce-batch-coalesce b) ; same-path runs collapse to the latest
(println-int (debounce-batch-len b)))
watch-kind-write = 1 file contents changed (Linux only; see §1)
watch-kind-create = 2 target appeared
watch-kind-delete = 3 target disappeared
watch-kind-rename = 4 inode changed -- typical for atomic-save
watch-kind-attrib = 5 metadata-only change
watch-kind-overflow = 6 backend queue overflowed; fall back to full rebuild
Always handle watch-kind-overflow explicitly. The backend's event queue
is bounded; under heavy load the OS drops events, and we surface that as
an explicit kind rather than silently missing changes. The right response
is usually "treat as 'everything changed' and re-scan from scratch."
Numeric values are part of the public ABI -- safe to read .kind
directly from inline-C if you really need to.
tur-notebook is the reference adopter -- tur nb render --watch
delegates to watch-open-one + watch-next + watch-close. The full
diff is small: open one watcher, loop on watch-next, free the event,
re-run the work, repeat. See
spices/notebook/src/notebook/cli.tur (search for __cli-watch-loop)
for the actual pattern.
The contract tur-watch v0.1.0 promises any adopter:
watch-close is safe to call from any state, including immediately
after a failed open.watch-kind-overflow
event, not a silent drop.What v0.1.0 does not yet provide:
watch-open -- single path only today.FSEvents backend, Windows backend, callback / async API.These land in v0.2 once a third real adopter (likely tur repl --watch)
forces the design.
tur repl --watch and tur-watchThe spice plan flags tur repl --watch as a candidate adopter (WT9). The
current REPL --watch lives in the C codebase at
turmeric/src/turi/repl.c and uses a polling mtime check
(tur_spice_image_is_fresh) right before each prompt evaluation. That
implementation is correct and simple, but the user only sees the reload
after they hit Enter -- it can't interrupt a long readline wait when
a file changes mid-edit.
A tur-watch-backed REPL --watch would:
watch-open-tree on the spice's src/ at startup,watch-next,That is a meaningful UX win but a non-trivial refactor inside the
turmeric C codebase, not a spice-side change. It is intentionally
deferred from tur-watch v0.1.0. Notebook is the proven adopter for
this release; the REPL can adopt later once someone is willing to do the
async interrupt work in repl.c.
If you are building a Turmeric-side REPL or task runner that wants
hot-reload-on-change behavior today, the loop in examples/rerun-command.tur
is the recommended pattern.