Current section
Files
Jump to
Current section
Files
src/polly.gleam
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)