import filespy import gleam/erlang/process import gleam/io import gleam/list import gleam/option.{type Option, None, Some} import gleam/otp/actor import gleam/string import shellout /// Phantom type to indicate no directories are added pub type NoDirectories /// Phantom type to indicate directories have been added pub type HasDirectories /// Phantom type to indicate builder has no initializer pub type NoInitializer /// Phantom type to indicate builder has initializer pub type HasInitializer /// Phantom type to indicate builder has no callback pub type NoCallback /// Phantom type to indicate builder has callback pub type HasCallback /// Phantom type to indicate builder has before callback pub type HasBeforeCallback /// Phantom type to indicate builder has no before callback pub type NoBeforeCallback /// Opaque builder type for reloader. Create with `new`. pub opaque type Builder( state, custom, has_dirs, has_initializer, has_callback, has_before_callback, ) { Builder( dirs: List(String), callback: fn(state, String) -> state, initializer: Option( fn(process.Subject(filespy.Change(custom))) -> Result( actor.Initialised( Nil, filespy.Change(custom), process.Subject(filespy.Change(custom)), ), custom, ), ), before_callback: Option(fn(state, String) -> state), ) } /// Construct a new builder pub fn new() -> Builder( Nil, Nil, NoDirectories, NoInitializer, NoCallback, NoBeforeCallback, ) { fn(state, path) { io.println("Change in " <> path <> ", reloading.") state } |> Builder(dirs: [], callback: _, initializer: None, before_callback: None) } /// Add a directory to watch. The path can be relative or absolute. /// macOS users: always use `"."` or an absolute path, otherwise it won't work /// properly! pub fn add_dir( builder: Builder( state, Nil, has_dirs, has_initializer, has_callback, has_before_callback, ), dir: String, ) -> Builder( state, Nil, HasDirectories, has_initializer, has_callback, has_before_callback, ) { Builder( dirs: [dir, ..builder.dirs], callback: builder.callback, initializer: builder.initializer, before_callback: builder.before_callback, ) } /// Add a callback to be run after reload pub fn on_reload( builder: Builder( state, Nil, has_dirs, has_initializer, NoCallback, has_before_callback, ), callback: fn(state, String) -> state, ) -> Builder( state, Nil, has_dirs, has_initializer, HasCallback, has_before_callback, ) { Builder( dirs: builder.dirs, callback: callback, initializer: builder.initializer, before_callback: builder.before_callback, ) } /// Add a callback to handle a process before reloading modules pub fn before_reload( builder: Builder( state, Nil, has_dirs, has_initializer, has_callback, NoBeforeCallback, ), before_callback: fn(state, String) -> state, ) -> Builder( state, Nil, has_dirs, has_initializer, has_callback, HasBeforeCallback, ) { Builder( dirs: builder.dirs, callback: builder.callback, initializer: builder.initializer, before_callback: Some(before_callback), ) } /// Add an initializer /// /// This will be run in the file watcher process, so it can be useful to, for /// example, add state to an actor pub fn set_initializer( builder: Builder( state, Nil, has_dirs, NoInitializer, has_callback, has_before_callback, ), initializer: fn(process.Subject(filespy.Change(Nil))) -> Result( actor.Initialised( Nil, filespy.Change(Nil), process.Subject(filespy.Change(Nil)), ), Nil, ), ) -> Builder( state, Nil, has_dirs, HasInitializer, has_callback, has_before_callback, ) { Builder( dirs: builder.dirs, callback: builder.callback, initializer: Some(initializer), before_callback: builder.before_callback, ) } type Module type What @external(erlang, "code", "modified_modules") fn modified_modules() -> List(Module) @external(erlang, "code", "purge") fn purge(module: Module) -> Bool @external(erlang, "code", "atomic_load") fn atomic_load(modules: List(Module)) -> Result(Nil, List(#(Module, What))) /// Start reloader, if no state has been set pub fn start( builder: Builder( Nil, Nil, HasDirectories, NoInitializer, has_callback, has_before_callback, ), ) { builder |> set_initializer(fn(subject) { let selector = process.new_selector() |> process.select(subject) actor.initialised(Nil) |> actor.returning(subject) |> actor.selecting(selector) |> Ok }) |> start_state() } /// Execute the file reload fn execute_reload( builder: Builder( state, Nil, HasDirectories, HasInitializer, has_callback, has_before_callback, ), state, path, ) -> state { let build_result = shellout.command(["build"], run: "gleam", in: ".", opt: []) case build_result { Ok(_output) -> { let mods = modified_modules() list.each(mods, purge) let _ = atomic_load(mods) builder.callback(state, path) } Error(#(_status, message)) -> { message |> io.print_error state } } } /// Start reloader, ensuring that a state has been set pub fn start_state( builder: Builder( Nil, Nil, HasDirectories, HasInitializer, has_callback, has_before_callback, ), ) { filespy.new() |> filespy.set_initializer(fn(subject) { let assert Some(init) = builder.initializer let initialised = init(subject) case initialised { Ok(initialised) -> { initialised |> actor.returning(subject) |> Ok } Error(_error) -> { Error("Error during initializing") } } }) |> filespy.add_dirs(builder.dirs) |> filespy.set_actor_handler(fn(state, msg) { case msg { filespy.Custom(_) -> actor.continue(state) filespy.Change(path, events) -> { // todo fold over state... gah! list.fold(events, state, fn(state, event) { case event { filespy.Closed | filespy.Modified -> { // Check if path ends in '.gleam' case string.ends_with(path, ".gleam") { True -> { case builder.before_callback { Some(before) -> { // If we have a before callback, we can run it before // executing the reload. let state = before(state, path) execute_reload(builder, state, path) } _ -> { execute_reload(builder, state, path) } } state } _ -> state } } _ -> state } }) actor.continue(state) } } }) |> filespy.start() }