Current section
Files
Jump to
Current section
Files
src/polly.gleam
import filepath
import gleam.{Error as Err}
import gleam/bool
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/result
import gleam/string
import simplifile.{type FileError, type FileInfo, Eacces, Enoent}
// Erlang - only dependencies
@target(erlang)
import gleam/erlang/process
@target(erlang)
import gleam/otp/actor
@target(erlang)
import gleam/otp/factory_supervisor
@target(erlang)
import gleam/otp/supervision
// -- 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(path: String)
/// A file got modified!
Changed(path: String)
/// A file or dictionary got deleted :(
Deleted(path: String)
/// An unexpected simplifile error happened :(
///
/// Polly treats `Enoent` and `Eacces` errors as if the file got deleted,
/// and will therefore never pass those to you.
Error(path: String, reason: FileError)
}
/// Polly uses the builder pattern to construct a watcher.
///
/// The `has_watched_dirs` phantom type variable helps Polly keep track of
/// wether or not you've called `add_dir` or `add_file` yet. That way, it will
/// be a type error if you try to watch nothing!
///
/// ```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: Filter,
ignore_initial_missing: Bool,
callbacks: List(Callback),
)
}
type Filter =
fn(simplifile.FileType, String) -> Bool
type Callback =
fn(Event) -> Nil
/// A polling file system watcher, built from options.
///
/// Watchers are automatically started, and can by stopped by calling `stop`.
pub opaque type Watcher {
Watcher(stop: fn() -> Nil)
}
// -- OPTIONS BUILDER ----------------------------------------------------------
/// Start creating a new configuration using the default options.
///
/// By default, an 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,
ignore_initial_missing: False,
callbacks: [],
)
}
/// Tell Polly which directory to watch. If it does not exist, `watch` will return an error.
///
/// If the directory goes away after watching has started, Polly will continue to
/// check on it to see if it came back.
///
/// 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])
}
/// Tell Polly to watch a single file.
///
/// Polly doesn't care if you tell her to watch a file or directory, but
/// using this function makes your intent clearer!
pub fn add_file(options: Options, path: String) -> Options {
add_dir(options, path)
}
/// Limit the maximum depth that Polly will walk each directory.
///
/// A limit of `0` would mean that Polly _only_ watches the specified list of
/// files or directories. A limit of `1` means that she will also look at the
/// files inside the given directories, but not at any nested directories.
///
/// There is no limit by default, but setting a limit might be good to
/// better control resource usage of the watcher.
///
/// Calling this function multiple times will cause polly to only remember the
/// lowest limit provided.
pub fn max_depth(options: Options, max_depth: Int) -> Options {
case options.max_depth < 0 || max_depth < options.max_depth {
True -> Options(..options, max_depth: max_depth)
False -> options
}
}
/// Set the interval in-between file-system polls, in milliseconds.
///
/// This is the time that Polly rests between calls, so if scanning your directory
/// tree takes 100ms, and you configured 1000ms here, the total time between calls
/// will roughly be 1100ms.
///
/// Doing it this way makes sure that Polly doesn't stumble over herself.
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.
///
/// Keep in mind that the filter is checked for every part of a path, not just
/// leaf nodes! So for example, if you have a path `./src/app.gleam`, your filter
/// function will first be called on `.`, then on `./src`, and then finally on
/// `./src/app.gleam`.
///
/// By default, all hidden files are ignored by using the `default_filter`.
pub fn filter(options: Options, by filter: Filter) -> Options {
Options(..options, filter: filter)
}
/// The default filter function, ignoring hidden files starting with a colon `"."`
pub fn default_filter(_type: simplifile.FileType, path: String) -> Bool {
case filepath.base_name(path) {
"." | ".." -> True
basename -> !string.starts_with(basename, ".")
}
}
/// Tell Polly that it is fine if a file or directory does not exist initially.
///
/// By default, if a file or directory cannot be found when calling `watch`,
/// Polly will immediately return to you with an `Enoent` error and refuse to run.
///
/// When this option is active, Polly will instead note the missing directory,
/// and continuously check if it appears, similarly to how she does after a
/// file or directory goes away after she has first seen it.
pub fn ignore_initial_missing(options: Options) -> Options {
Options(..options, ignore_initial_missing: True)
}
/// Add a callback function that Polly will call whenever she spots an event.
///
/// You can add multiple callbacks, and Polly will call them all in the order
/// you added them. The callbacks are called synchronously while she's collecting
/// change events, so it's a good idea to keep them light and fast!
pub fn add_callback(
options: Options,
on_event callback: fn(Event) -> Nil,
) -> Options {
Options(..options, callbacks: [callback, ..options.callbacks])
}
@target(erlang)
/// Add a subject that Polly will send events to.
///
/// This is a convenience wrapper around `add_callback` for when you want to
/// send events to a process subject. Polly will use `process.send` to deliver
/// each event to your subject.
pub fn add_subject(
options: Options,
on_event subject: process.Subject(Event),
) -> Options {
add_callback(options, on_event: process.send(subject, _))
}
// -- WATCH API ----------------------------------------------------------------
/// Tell Polly to start watching all the specified directories for changes.
///
/// The callbacks are called synchronously while collecting change events since
/// the last run. It is adviseable to move heavier cpu-bound tasks from this
/// callback into their own processes or threads.
///
/// When running on the Erlang target, this spawns a new linked process.
pub fn watch(options: Options) -> Result(Watcher, List(#(String, FileError))) {
use roots <- result.try(get_initial_roots(options))
let emit = broadcast(options.callbacks, _)
let stop = repeatedly(options.interval, roots, step(options, emit, _))
Ok(Watcher(stop:))
}
@target(erlang)
/// Create a child specification for running Polly under a supervisor.
///
/// This lets you add Polly to your supervision tree, so she'll automatically
/// restart if something goes wrong. The supervisor will make sure she keeps
/// watching your files reliably!
pub fn supervised(options: Options) -> supervision.ChildSpecification(Watcher) {
use <- supervision.worker
start_process(options)
}
@target(erlang)
/// Create a factory builder for dynamically starting multiple Polly watchers.
///
/// This is useful when you want to spawn and manage multiple watchers at runtime,
/// each watching different paths with different options. The factory supervisor
/// handles starting, stopping, and supervising all your Polly instances!
pub fn factory() -> factory_supervisor.Builder(Options, Watcher) {
use options <- factory_supervisor.worker_child
start_process(options)
}
@target(erlang)
fn start_process(
options: Options,
) -> Result(actor.Started(Watcher), actor.StartError) {
use roots <- result.try(
get_initial_roots(options)
|> result.map_error(fn(errors) { actor.InitFailed(describe_errors(errors)) }),
)
let emit = broadcast(options.callbacks, _)
let #(pid, stop) = spawn(options.interval, roots, step(options, emit, _))
Ok(actor.Started(pid, Watcher(stop)))
}
fn get_initial_roots(options: Options) {
let Options(paths:, max_depth:, filter:, ignore_initial_missing:, ..) =
options
use <- bool.guard(when: paths == [], return: Err([]))
use path <- list.try_map(paths)
case init(filter, max_depth, path, "", []) {
#(Some(vfs), []) -> Ok(#(path, Some(vfs)))
// if the directory is not there on hydrate, this is an error by default.
#(None, []) ->
case ignore_initial_missing {
False -> Err([#(path, Enoent)])
True -> Ok(#(path, None))
}
#(_, errors) -> Err(errors)
}
}
fn step(
options: Options,
emit: fn(Event) -> Nil,
roots: List(#(String, Option(Vfs))),
) -> List(#(String, Option(Vfs))) {
let Options(max_depth:, filter:, ..) = options
use roots, #(path, vfs) <- list.fold(roots, [])
case vfs {
Some(vfs) -> {
let new_vfs = diff(filter, max_depth, path, vfs, emit)
[#(path, new_vfs), ..roots]
}
// check if the directory came back!
option.None -> {
let new_vfs = create(filter, max_depth, path, "", emit)
[#(path, new_vfs), ..roots]
}
}
}
fn broadcast(callbacks: List(fn(Event) -> Nil), event: Event) {
use callback <- list.each(callbacks)
callback(event)
}
/// Stop this watcher.
///
/// If Polly currently scans your directories, she might not hear you right away
/// and may still report events for one run, after which she will stop.
pub fn stop(watcher: Watcher) -> Nil {
watcher.stop()
}
/// Format a list of file errors into a human-readable string.
///
/// This is handy when `watch` returns errors and you want to show them to
/// your users. Each error is formatted as "path: description" on its own line.
pub fn describe_errors(errors: List(#(String, FileError))) -> String {
errors
|> list.map(fn(error) {
let #(path, error) = error
path <> ": " <> simplifile.describe_error(error)
})
|> string.join("\n")
}
// -- IMPLEMENTATION -----------------------------------------------------------
type Vfs {
File(name: String, modkey: Int)
Folder(name: String, modkey: Int, children: List(Vfs))
}
fn init(
filter: Filter,
depth: Int,
path: String,
name: String,
errors: List(#(String, FileError)),
) -> #(Option(Vfs), List(#(String, FileError))) {
let full_path = filepath.join(path, name)
case simplifile.link_info(full_path) {
Ok(stat) -> init_stat(filter, depth, name, full_path, stat, errors)
Err(Enoent) | Err(Eacces) -> #(None, errors)
Err(reason) -> #(None, [#(full_path, reason), ..errors])
}
}
fn init_stat(
filter: Filter,
depth: Int,
name: String,
full_path: String,
stat: FileInfo,
errors: List(#(String, FileError)),
) -> #(Option(Vfs), List(#(String, FileError))) {
let type_ = simplifile.file_info_type(stat)
use <- bool.guard(when: !filter(type_, full_path), return: #(None, errors))
case type_ {
simplifile.File -> {
#(Some(File(name, get_modkey(stat))), errors)
}
simplifile.Directory if depth == 0 -> {
#(Some(Folder(name, get_modkey(stat), [])), errors)
}
simplifile.Directory if depth != 0 -> {
case readdir(full_path) {
Ok(entries) -> {
let depth = depth - 1
let #(children, errors) =
init_children(filter, depth, full_path, entries, [], errors)
#(Some(Folder(name, get_modkey(stat), children)), errors)
}
Err(Enoent) | Err(Eacces) -> #(None, errors)
Err(reason) -> #(None, [#(full_path, reason), ..errors])
}
}
_ -> #(None, [])
}
}
fn init_children(
filter: Filter,
depth: Int,
path: String,
children: List(String),
oks: List(Vfs),
errors: List(#(String, FileError)),
) -> #(List(Vfs), List(#(String, FileError))) {
case children {
[] -> #(list.reverse(oks), errors)
[first, ..rest] ->
case init(filter, depth, path, first, errors) {
#(Some(vfs), errors) ->
init_children(filter, depth, path, rest, [vfs, ..oks], errors)
#(None, errors) -> init_children(filter, depth, path, rest, oks, errors)
}
}
}
fn create(
filter: Filter,
depth: Int,
path: String,
name: String,
emit: fn(Event) -> Nil,
) -> Option(Vfs) {
let full_path = filepath.join(path, name)
case simplifile.link_info(full_path) {
Ok(stat) -> create_stat(filter, depth, name, full_path, stat, emit)
Err(Enoent) | Err(Eacces) -> None
Err(reason) -> {
emit(Error(full_path, reason))
None
}
}
}
fn create_stat(
filter: Filter,
depth: Int,
name: String,
full_path: String,
stat: FileInfo,
emit: fn(Event) -> Nil,
) -> Option(Vfs) {
let type_ = simplifile.file_info_type(stat)
use <- bool.guard(when: !filter(type_, full_path), return: None)
case type_ {
simplifile.File -> {
emit(Created(full_path))
Some(File(name, get_modkey(stat)))
}
simplifile.Directory if depth == 0 -> {
emit(Created(full_path))
Some(Folder(name, get_modkey(stat), []))
}
simplifile.Directory if depth != 0 -> {
case readdir(full_path) {
Ok(entries) -> {
let depth = depth - 1
emit(Created(full_path))
let children =
create_children(filter, depth, full_path, entries, [], emit)
Some(Folder(name, get_modkey(stat), children))
}
Err(Enoent) | Err(Eacces) -> None
Err(reason) -> {
emit(Error(full_path, reason))
None
}
}
}
_ -> None
}
}
fn create_children(
filter: Filter,
depth: Int,
path: String,
children: List(String),
oks: List(Vfs),
emit: fn(Event) -> Nil,
) -> List(Vfs) {
case children {
[] -> list.reverse(oks)
[first, ..rest] ->
case create(filter, depth, path, first, emit) {
Some(vfs) ->
create_children(filter, depth, path, rest, [vfs, ..oks], emit)
None -> create_children(filter, depth, path, rest, oks, emit)
}
}
}
fn diff(
filter: Filter,
depth: Int,
path: String,
vfs: Vfs,
emit: fn(Event) -> Nil,
) -> Option(Vfs) {
let full_path = filepath.join(path, vfs.name)
case simplifile.link_info(full_path) {
Ok(stat) -> {
let type_ = simplifile.file_info_type(stat)
case filter(type_, full_path) {
True ->
case type_, vfs {
simplifile.File, File(name:, modkey: old_key) -> {
let new_key = get_modkey(stat)
case new_key == old_key {
// hurray for reuse
True -> Some(vfs)
False -> {
emit(Changed(full_path))
Some(File(name, new_key))
}
}
}
simplifile.Directory, Folder(..) if depth == 0 -> {
// NOTE: we do not send change events for directories, so there is nothing to do.
Some(vfs)
}
simplifile.Directory, Folder(name:, children: old_children, ..)
if depth != 0
->
case readdir(full_path) {
Ok(new_entries) -> {
let children =
diff_children(
filter,
depth - 1,
full_path,
old_children,
new_entries,
[],
emit,
)
Some(Folder(name, get_modkey(stat), children))
}
Err(Enoent) | Err(Eacces) -> {
delete(path, vfs, emit)
None
}
Err(reason) -> {
emit(Error(full_path, reason))
Some(vfs)
}
}
_, _ -> {
delete(path, vfs, emit)
create_stat(filter, depth, vfs.name, full_path, stat, emit)
}
}
// we had a VFS, but now we should skip it -> emit deletes
False -> {
delete(path, vfs, emit)
None
}
}
}
// enoent might happen if the file is deleted while we are scanning
Err(Eacces) | Err(Enoent) -> {
delete(path, vfs, emit)
None
}
Err(reason) -> {
emit(Error(path, reason))
Some(vfs)
}
}
}
fn diff_children(
filter: Filter,
depth: Int,
path: String,
old_children: List(Vfs),
new_entries: List(String),
new_children: List(Vfs),
emit: fn(Event) -> Nil,
) -> List(Vfs) {
case old_children, new_entries {
[], [] -> list.reverse(new_children)
[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, emit) {
Some(new_vfs) ->
diff_children(
filter,
depth,
path,
rest_old,
rest_new,
[new_vfs, ..new_children],
emit,
)
None ->
diff_children(
filter,
depth,
path,
rest_old,
rest_new,
new_children,
emit,
)
}
}
order.Gt -> {
// created a file
let new_children = case create(filter, depth, path, first_new, emit) {
Some(new_vfs) -> [new_vfs, ..new_children]
None -> new_children
}
diff_children(
filter,
depth,
path,
old_children,
rest_new,
new_children,
emit,
)
}
order.Lt -> {
// deleted a file
delete(path, first_old, emit)
diff_children(
filter,
depth,
path,
rest_old,
new_entries,
new_children,
emit,
)
}
}
[], [first_new, ..rest_new] -> {
let new_children = case create(filter, depth, path, first_new, emit) {
Some(new_vfs) -> [new_vfs, ..new_children]
None -> new_children
}
diff_children(
filter,
depth,
path,
old_children,
rest_new,
new_children,
emit,
)
}
[first_old, ..rest_old], [] -> {
// deleted all the remaining old ones
delete(path, first_old, emit)
diff_children(
filter,
depth,
path,
rest_old,
new_entries,
new_children,
emit,
)
}
}
}
fn delete(path: String, vfs: Vfs, emit: fn(Event) -> Nil) -> Nil {
let full_path = filepath.join(path, vfs.name)
case vfs {
File(..) -> emit(Deleted(full_path))
Folder(children:, ..) -> {
list.each(children, fn(child) { delete(full_path, child, emit) })
emit(Deleted(full_path))
}
}
}
fn get_modkey(stat: simplifile.FileInfo) -> Int {
int.max(stat.mtime_seconds, stat.ctime_seconds)
}
fn readdir(path: String) -> Result(List(String), FileError) {
simplifile.read_directory(path)
|> result.map(list.sort(_, by: string.compare))
}
// -- EXTERNAL -----------------------------------------------------------------
@external(erlang, "polly_ffi", "repeatedly")
@external(javascript, "./polly_ffi.mjs", "repeatedly")
fn repeatedly(
timeout: Int,
initial state: state,
with callback: fn(state) -> state,
) -> fn() -> Nil
@target(erlang)
@external(erlang, "polly_ffi", "spawn")
fn spawn(
timeout: Int,
initial state: state,
with callback: fn(state) -> state,
) -> #(process.Pid, fn() -> Nil)