std/watch

watch(path, options, callback) watches a file or a directory and gives each change to callback as a FileEvent while the loop of std/loop runs. An open watcher keeps the loop running until it is closed:

import "std/loop"
import "std/watch"

WatchOptions options = WatchOptions()
options.debounce_ms = 100
options.ignore = [".git", "build", "*.tmp"]
Watcher sources = watch("src", options, func(FileEvent e) do
    match e.change do
        when RENAMED then
            print("moved", e.old_path, "to", e.path)
        when OVERFLOW then
            print("too much changed; reading", e.path, "again")
        else
            print(e.change, e.path)
    end
end)?
run_loop()

A FileEvent has the change, the path, which is the watched path joined with the path below it with / between names, the old_path of a rename, and the message of a failure:

Change Meaning
CREATED the path appeared, made there or moved in from outside what is watched
CHANGED a file's contents or times changed
REMOVED the path went away, removed or moved outside what is watched
RENAMED it moved from old_path to path, both inside what is watched, also from one folder to another
OVERFLOW more changed than the system could keep track of, below path, which must be read again
FAILED watching failed, as message says: for one directory, while the rest goes on, or for all of it

WatchOptions sets how it watches:

Field Meaning
recursive true (the default) takes in every directory below; false only the directory's own entries
debounce_ms above 0, changes wait until that many milliseconds pass without another, and those to one path become one: made and then changed is made, made and then removed is nothing, removed and made again is changed
ignore patterns of paths to leave out, with everything inside a directory that matches

Patterns are matched against the path below the watched directory: * stands for any characters but /, ? for one, and ** for any characters, / too, and a pattern without / is matched against each name of the path, so "build" leaves out every directory named build and all that is in it.

Function Result
watch(path, options, callback) a Result[Watcher]; a path that is not there is an ERROR of kind "not_found"
close_watcher(w) stops watching; changes that wait for a debounce are dropped too. with closes a watcher as well
is_watching(w) whether it still watches: it stops when it is closed, and when the watched path goes away, which comes as REMOVED for that path
next_change(path, options) a Task[FileEvent] that settles with the first change, and stops watching

A watched file is followed through its directory, so a file that an editor replaces when it saves comes as CHANGED, and one removed and made again as REMOVED and then CREATED. A directory's own times are not changes: what happens to its entries is told for them. Changes to one path come in the order they happened, but the systems differ in the details, so a program should take a change as a reason to look at the path, not as an exact record.

Each system has its own way to watch:

  • Linux uses inotify, with a watch for each directory. A directory that appears is watched at once, and what was made in it before then is told as CREATED. inotify has a limit of watched directories for each user; reaching it is an error, or a FAILED change for a directory that appears later, that names fs.inotify.max_user_watches.
  • macOS uses FSEvents, which tells changes a moment after they happen.
  • Windows uses ReadDirectoryChangesExW, which also pairs a move from one folder to another; on file systems without it, such as some network shares, such a move comes as REMOVED and CREATED.
  • The BSDs use kqueue, which needs a descriptor for every file and directory, so a large tree can reach the limit of open files (ulimit -n). Setting CUARZO_WATCH_KQUEUE makes macOS watch this way too.