import filepath import gleam/bool import gleam/int import gleam/list import gleam/order import gleam/result import gleam/string import repeatedly // -- TYPES -------------------------------------------------------------------- /// A filesystem event. /// /// Polly is careful when handing you events, and makes sure that you always get /// them in the right order, so if a dictionary with some files was created, /// you will get the dictionary event first, before any children. pub type Event { /// A new file or dictionary was created! Created(String) /// A file got modified! Changed(String) /// A file or dictionary got deleted :( Deleted(String) } /// Some POSIX file system errors. /// /// Only a handful of errors are handled by Polly, and everything else is collapsed into `EUnknown`. pub type FsError { Eacces Enoent Enotdir EUnknown(String) } /// Polly uses the builder pattern to construct a watcher. /// /// ```gleam /// let options: Options = polly.new() /// |> polly.add_dir("src") /// |> polly.interval(3000) /// |> polly.max_depth(10) /// |> polly.filter(polly.default_filter) /// ``` pub opaque type Options { Options( interval: Int, paths: List(String), max_depth: Int, filter: fn(String) -> Bool, ) } /// A polling file system watcher, built from options. /// /// Watchers are automatically started, and can by stopped by calling `stop`. pub opaque type Watcher(state) { Watcher(repeater: repeatedly.Repeater(State(state))) } // -- OPTIONS BUILDER ---------------------------------------------------------- /// Start creating a new configuration using the default options. /// /// By default, and interval of 1 second is set, and the `default_filter` is used. pub fn new() -> Options { Options(interval: 1000, paths: [], max_depth: -1, filter: default_filter) } /// Tell Polly which directory to watch. If it does not exist, `watch` will return an error. /// /// Paths are not expanded by default, so the paths reported by events and passed /// to the filter function will be prefixed with whatever you specified here. pub fn add_dir(options: Options, path: String) -> Options { Options(..options, paths: [path, ..options.paths]) } /// Limit the maximum depth that Polly will walk each directory. /// /// There is no limit by default, but setting a limit might be a good to /// better control resource usage of the watcher. pub fn max_depth(options: Options, max_depth: Int) -> Options { case options.max_depth < 0, options.max_depth > max_depth { True, _ | False, True -> Options(..options, max_depth: max_depth) False, False -> options } } /// Set the interval in-between file-system polls, in milliseconds. /// /// This options is passed through to [repeatedly.call](https://hexdocs.pm/repeatedly/repeatedly.html#call). pub fn interval(options: Options, interval: Int) -> Options { case interval > 0 { True -> Options(..options, interval: interval) False -> options } } /// Filter files using the given predicate. /// /// Polly will ignore files and directories for which the predicate returns `False` /// completely, and any event happening for them or for a contained file of them /// will not get reported. /// /// By default, all hidden files are ignored. pub fn filter(options: Options, by filter: fn(String) -> Bool) -> Options { Options(..options, filter: filter) } /// The default filter function, ignoring hidden files starting with a colon `"."` pub fn default_filter(path: String) -> Bool { let is_hidden = filepath.base_name(path) |> string.starts_with(".") is_hidden == False } // -- WATCH API ---------------------------------------------------------------- type State(state) = #(List(#(String, Vfs)), state) /// Tell Polly to start watching all the specified directories for changes. /// /// The callback is called synchronously after collecting all change events since /// the last run. It is adviseable to move heavier cpu-bound tasks from this /// callback into their own processes/threads. /// /// On Erlang, the callback will be called in the same process as the watcher runs in. pub fn watch( options: Options, with callback: fn(Event) -> ignored, ) -> Result(Watcher(Nil), #(String, FsError)) { watch_with(options, Nil, fn(_, event) { callback(event) Nil }) } /// Like `watch`, but similar to `list.fold` Polly will also keep some state /// around for you and pass it back on each invocation. pub fn watch_with( options: Options, from initial: state, with callback: fn(state, Event) -> state, ) -> Result(Watcher(state), #(String, FsError)) { use roots <- result.try({ use path <- list.try_map(options.paths) use #(vfs, _) <- result.map( hydrate(options.filter, options.max_depth, path, "") |> to_result(path), ) #(path, vfs) }) let state: State(state) = #(roots, initial) let repeater = repeatedly.call(options.interval, state, fn(state, _) { let #(roots, user_state) = state use #(new_roots, user_state), #(path, vfs) <- list.fold(roots, #( [], user_state, )) let assert Success(vfs, events) = diff(options.filter, options.max_depth, path, vfs, []) let user_state = list.fold(events, from: user_state, with: callback) #([#(path, vfs), ..new_roots], user_state) }) Ok(Watcher(repeater)) } /// Stop this watcher. /// /// On Erlang, pending events may still be reported. /// See the documentation of [repeatedly.stop](https://hexdocs.pm/repeatedly/repeatedly.html#stop) for more information. pub fn stop(watcher: Watcher(state)) -> Nil { repeatedly.stop(watcher.repeater) } fn to_result( result: HydrationResult, path: String, ) -> Result(#(Vfs, List(Event)), #(String, FsError)) { case result { Success(vfs, events) -> Ok(#(vfs, events)) Skipped -> Error(#(path, Enoent)) Errored(path, err) -> Error(#(path, err)) } } // -- IMPLEMENTATION ----------------------------------------------------------- type Vfs { File(name: String, modkey: Int) Folder(name: String, modkey: Int, children: List(Vfs)) } type HydrationResult { Success(Vfs, List(Event)) Skipped Errored(path: String, reason: FsError) } fn hydrate( filter: fn(String) -> Bool, depth: Int, path: String, name: String, ) -> HydrationResult { let full_path = filepath.join(path, name) use <- bool.guard(when: !filter(full_path), return: Skipped) case lstat(full_path) { Ok(stat) -> hydrate_stat(filter, depth, name, full_path, stat) Error(Eacces) | Error(Enoent) -> Skipped Error(e) -> Errored(full_path, e) } } fn try( path: String, result: Result(a, FsError), then: fn(a) -> HydrationResult, ) -> HydrationResult { case result { Ok(value) -> then(value) Error(err) -> Errored(path, err) } } fn hydrate_stat( filter: fn(String) -> Bool, depth: Int, name: String, full_path: String, stat: Stat, ) -> HydrationResult { // Only watch writable files, what are doing watching files that cannot change? use <- bool.guard(when: stat.access != ReadWrite, return: Skipped) case stat.type_ { Regular -> Success(File(name, get_modkey(stat)), []) Directory if depth == 0 -> { Success(Folder(name, get_modkey(stat), []), []) } Directory if depth != 0 -> { use entries <- try(full_path, readdir(full_path)) case hydrate_children(filter, depth - 1, full_path, entries, []) { Ok(children) -> Success(Folder(name, get_modkey(stat), children), []) Error(#(path, reason)) -> Errored(path, reason) } } _ -> Skipped } } fn hydrate_children( filter: fn(String) -> Bool, depth: Int, path: String, children: List(String), acc: List(Vfs), ) -> Result(List(Vfs), #(String, FsError)) { case children { [] -> Ok(list.reverse(acc)) [first, ..rest] -> case hydrate(filter, depth, path, first) { Success(vfs, _) -> hydrate_children(filter, depth, path, rest, [vfs, ..acc]) Skipped -> hydrate_children(filter, depth, path, rest, acc) Errored(path, err) -> Error(#(path, err)) } } } fn diff( filter: fn(String) -> Bool, depth: Int, path: String, vfs: Vfs, events: List(Event), ) -> HydrationResult { let full_path = filepath.join(path, vfs.name) use <- bool.guard(when: !filter(full_path), return: Skipped) case lstat(full_path) { Ok(stat) -> { // Only watch writable files, what are doing watching files that cannot change? use <- bool.guard(when: stat.access != ReadWrite, return: Skipped) case stat.type_, vfs { Regular, File(name:, modkey: old_key) -> { let new_key = get_modkey(stat) case new_key == old_key { True -> Success(vfs, events) // hurray for reuse False -> Success(File(name:, modkey: new_key), [ Changed(full_path), ..events ]) } } Directory, Folder(..) if depth == 0 -> { // TODO: we do not send change events for directories, so there is nothing to do. Success(vfs, events) } Directory, Folder(name:, modkey: _old_key, children: old_children) if depth != 0 -> { let new_key = get_modkey(stat) // TODO: do we even want change events from dictionaries?? use new_entries <- try(full_path, readdir(full_path)) diff_children( filter, depth - 1, full_path, name, new_key, old_children, new_entries, [], events, ) } _, _ -> case hydrate_stat(filter, depth, vfs.name, full_path, stat) { Success(new_vfs, _) -> { let events = list.append(deleted(path, vfs), created(path, new_vfs)) Success(new_vfs, events) } _ as res -> res } } } // enoent might happen if the file is deleted while we are scanning Error(Eacces) | Error(Enoent) -> Skipped Error(e) -> { Errored(path, e) } } } fn diff_children( filter: fn(String) -> Bool, depth: Int, path: String, name: String, modkey: Int, old_children: List(Vfs), new_entries: List(String), new_children: List(Vfs), events: List(Event), ) -> HydrationResult { case old_children, new_entries { [], [] -> Success(Folder(name, modkey, list.reverse(new_children)), events) [first_old, ..rest_old], [first_new, ..rest_new] -> case string.compare(first_old.name, first_new) { order.Eq -> { case diff(filter, depth, path, first_old, events) { Success(new_vfs, events) -> { let new_children = [new_vfs, ..new_children] diff_children( filter, depth, path, name, modkey, rest_old, rest_new, new_children, events, ) } Skipped -> { // the old file was here, but now we should skip it? // we treat this the same as if the file got deleted. let events = list.append(deleted(path, first_old), events) diff_children( filter, depth, path, name, modkey, rest_old, rest_new, new_children, events, ) } Errored(_, _) as err -> err } } order.Gt -> { // created a file case hydrate(filter, depth, path, first_new) { Success(new_vfs, _) -> { let events = list.append(created(path, new_vfs), events) let new_children = [new_vfs, ..new_children] diff_children( filter, depth, path, name, modkey, old_children, rest_new, new_children, events, ) } Skipped -> diff_children( filter, depth, path, name, modkey, old_children, rest_new, new_children, events, ) Errored(_, _) as err -> err } } order.Lt -> { // deleted a file let events = list.append(deleted(path, first_old), events) diff_children( filter, depth, path, name, modkey, rest_old, new_entries, new_children, events, ) } } [], [first_new, ..rest_new] -> { case hydrate(filter, depth, path, first_new) { Success(new_vfs, _) -> { let events = list.append(created(path, new_vfs), events) let new_children = [new_vfs, ..new_children] diff_children( filter, depth, path, name, modkey, old_children, rest_new, new_children, events, ) } Skipped -> diff_children( filter, depth, path, name, modkey, old_children, rest_new, new_children, events, ) Errored(_, _) as err -> err } } [first_old, ..rest_old], [] -> { // deleted all the remaining old ones let events = list.append(deleted(path, first_old), events) diff_children( filter, depth, path, name, modkey, rest_old, new_entries, new_children, events, ) } } } fn created(path: String, vfs: Vfs) -> List(Event) { list.reverse(do_created(path, vfs, [])) } fn do_created(path: String, vfs: Vfs, acc: List(Event)) -> List(Event) { let full_path = filepath.join(path, vfs.name) let acc = [Created(full_path), ..acc] case vfs { File(..) -> acc Folder(children:, ..) -> { use acc, child <- list.fold(children, acc) do_created(full_path, child, acc) } } } fn deleted(path: String, vfs: Vfs) -> List(Event) { // no list.reverse - deleted events are in reverse order from created do_deleted(path, vfs, []) } fn do_deleted(path: String, vfs: Vfs, acc: List(Event)) -> List(Event) { let full_path = filepath.join(path, vfs.name) let acc = [Deleted(full_path), ..acc] case vfs { File(..) -> acc Folder(children:, ..) -> { use acc, child <- list.fold(children, acc) do_deleted(full_path, child, acc) } } } fn get_modkey(stat: Stat) -> Int { int.max(stat.mtime, stat.ctime) } // -- EXTERNAL ----------------------------------------------------------------- @internal pub type Stat { Stat( size: Int, type_: FileType, access: FileAccess, atime: Int, mtime: Int, ctime: Int, mode: Int, links: Int, major_device: Int, // character devices on unix minor_device: Int, // not on windows inode: Int, // not on windows uid: Int, // not on windows gid: Int, ) } @internal pub type FileType { Device Directory Symlink Other Regular } @internal pub type FileAccess { Read Write ReadWrite None } fn map_error(result: Result(a, FsError)) -> Result(a, FsError) { use err <- result.map_error(result) case err { Eacces | Enotdir | Enoent -> err _ -> EUnknown(string.inspect(err)) } } fn readdir(path: String) -> Result(List(String), FsError) { do_readdir(path) |> map_error |> result.map(list.sort(_, by: string.compare)) } @external(erlang, "polly_ffi", "readdir") @external(javascript, "./polly_ffi.mjs", "readdir") fn do_readdir(path: String) -> Result(List(String), FsError) fn lstat(path: String) -> Result(Stat, FsError) { map_error(do_lstat(path)) } @external(erlang, "polly_ffi", "lstat") @external(javascript, "./polly_ffi.mjs", "lstat") fn do_lstat(path: String) -> Result(Stat, FsError)