diff options
-rw-r--r-- | gleam.toml | 1 | ||||
-rw-r--r-- | manifest.toml | 4 | ||||
-rw-r--r-- | src/lustre/cli/add.gleam | 6 | ||||
-rw-r--r-- | src/lustre/cli/build.gleam | 122 | ||||
-rw-r--r-- | src/lustre/cli/dev.gleam | 60 | ||||
-rw-r--r-- | src/lustre/cli/esbuild.gleam | 104 | ||||
-rw-r--r-- | src/lustre/cli/project.gleam | 36 | ||||
-rw-r--r-- | src/lustre/cli/step.gleam | 100 |
8 files changed, 323 insertions, 110 deletions
@@ -52,6 +52,7 @@ justin = "~> 1.0" shellout = "~> 1.5" simplifile = "~> 1.4" tom = "~> 0.3" +spinner = "~> 1.1" [dev-dependencies] birdie = "~> 1.0" diff --git a/manifest.toml b/manifest.toml index a6d82df..cec0024 100644 --- a/manifest.toml +++ b/manifest.toml @@ -12,13 +12,16 @@ packages = [ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" }, { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" }, { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, + { name = "glearray", version = "0.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "908154F695D330E06A37FAB2C04119E8F315D643206F8F32B6A6C14A8709FFF4" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, { name = "glint", version = "0.16.0-rc1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "AF9A7CCAD9FC8CDFF2606F97BC52C2D98559F27EB28CD3B8A11A35641740AE2F" }, { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, + { name = "repeatedly", version = "2.1.0", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "AF58F2AF775BAAD1C4B4C74F9B5D963E50B71736C97A7323DBA40F809CF93E5A" }, { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, { name = "simplifile", version = "1.4.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "AAFCF154F69B237D269FF2764890F61ABC4A7EF2A592D44D67627B99694539D9" }, { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, + { name = "spinner", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_erlang", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "200BA3D4A04D468898E63C0D316E23F526E02514BC46454091975CB5BAE41E8F" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, ] @@ -37,4 +40,5 @@ glint = { version = "0.16.0-rc1" } justin = { version = "~> 1.0" } shellout = { version = "~> 1.5" } simplifile = { version = "~> 1.4" } +spinner = { version = "~> 1.1"} tom = { version = "~> 0.3" } diff --git a/src/lustre/cli/add.gleam b/src/lustre/cli/add.gleam index 5fc53da..7e44334 100644 --- a/src/lustre/cli/add.gleam +++ b/src/lustre/cli/add.gleam @@ -3,6 +3,7 @@ import glint.{type Command, CommandInput} import glint/flag import lustre/cli/esbuild +import lustre/cli/step // COMMANDS -------------------------------------------------------------------- @@ -17,9 +18,8 @@ to bundle applications and act as a development server. let CommandInput(flags: flags, ..) = input let assert Ok(os) = flag.get_string(flags, "os") let assert Ok(cpu) = flag.get_string(flags, "cpu") - let result = esbuild.download(os, cpu) - - case result { + let script = esbuild.download(os, cpu) + case step.execute(script) { Ok(_) -> Nil Error(error) -> esbuild.explain(error) } diff --git a/src/lustre/cli/build.gleam b/src/lustre/cli/build.gleam index 1314f9e..8d02b97 100644 --- a/src/lustre/cli/build.gleam +++ b/src/lustre/cli/build.gleam @@ -10,7 +10,8 @@ import glint.{type Command, CommandInput} import glint/flag import lustre/cli/esbuild import lustre/cli/project.{type Module, type Type, Named, Variable} -import lustre/cli/utils.{map, replace, try} +import lustre/cli/utils.{keep, replace, try} +import lustre/cli/step.{type Step} import simplifile // COMMANDS -------------------------------------------------------------------- @@ -30,20 +31,18 @@ JavaScript module for you to host or distribute. let CommandInput(flags: flags, ..) = input let assert Ok(minify) = flag.get_bool(flags, "minify") - let result = { - use _ <- result.try(prepare_esbuild()) + let script = { + use <- step.new("Building your project") + use project_name <- step.try(get_project_name(), keep) + use <- step.done("✅ Project compiled successfully") + use <- step.new("Checking if I can bundle your application") + use module <- step.try(get_module_interface(project_name), keep) + use _ <- step.try(check_main_function(project_name, module), keep) - io.println("\nPreparing build...") - io.println(" ├ reading project config") - use project_name <- result.try(get_project_name()) - use module <- result.try(get_module_interface(project_name)) - use _ <- result.try(check_main_function(project_name, module)) - - io.println(" ├ generating entry file") + use <- step.new("Creating the bundle entry file") let root = project.root() let tempdir = filepath.join(root, "build/.lustre") let outdir = filepath.join(root, "priv/static") - let _ = simplifile.create_directory_all(tempdir) let _ = simplifile.create_directory_all(outdir) let entry = @@ -65,12 +64,12 @@ JavaScript module for you to host or distribute. |> filepath.join(outdir, _) let assert Ok(_) = simplifile.write(entryfile, entry) - use _ <- result.try(bundle(entry, tempdir, outfile, minify)) - Ok(Nil) + use _ <- step.run(bundle(entry, tempdir, outfile, minify), keep) + step.return(Nil) } - case result { + case step.execute(script) { Ok(_) -> Nil Error(error) -> explain(error) } @@ -100,26 +99,26 @@ present. let assert Ok(module_path) = dict.get(args, "module_path") let assert Ok(minify) = flag.get_bool(flags, "minify") - let result = { - io.println("\nPreparing build...") - io.println(" ├ reading project config") - use module <- result.try(get_module_interface(module_path)) - use _ <- result.try(check_component_name(module_path, module)) - use component <- result.try(find_component(module_path, module)) + let script = { + use <- step.new("Building your project") + use module <- step.try(get_module_interface(module_path), keep) + use <- step.done("✅ Project compiled successfully") + use <- step.new("Checking if I can bundle your component") + use _ <- step.try(check_component_name(module_path, module), keep) + use component <- step.try(find_component(module_path, module), keep) - io.println(" ├ generating entry file") + use <- step.new("Creating the bundle entry file") let root = project.root() let tempdir = filepath.join(root, "build/.lustre") let outdir = filepath.join(root, "priv/static") - let _ = simplifile.create_directory_all(tempdir) let _ = simplifile.create_directory_all(outdir) - use project_name <- result.try(get_project_name()) + use project_name <- step.try(get_project_name(), keep) let entry = "import { register } from '../dev/javascript/lustre/client-component.ffi.mjs'; import { name, ${component} as component } from '../dev/javascript/${project_name}/${module_path}.mjs'; - + register(component(), name); " |> string.replace("${component}", component) @@ -138,19 +137,18 @@ present. |> result.map(filepath.join(outdir, _)) let assert Ok(_) = simplifile.write(entryfile, entry) - use _ <- result.try(bundle(entry, tempdir, outfile, minify)) - - Ok(Nil) + use _ <- step.run(bundle(entry, tempdir, outfile, minify), keep) + step.return(Nil) } - case result { + case step.execute(script) { Ok(_) -> Nil Error(error) -> explain(error) } }) |> glint.description(description) |> glint.named_args(["module_path"]) - |> glint.unnamed_args(glint.EqArgs(1)) + |> glint.unnamed_args(glint.EqArgs(0)) |> glint.flag("minify", { let description = "Minify the output" let default = False @@ -174,18 +172,49 @@ type Error { } fn explain(error: Error) -> Nil { - error - |> string.inspect - |> io.println -} + case error { + BuildError -> project.explain(project.BuildError) -// STEPS ----------------------------------------------------------------------- + BundleError(error) -> esbuild.explain(error) + + ComponentMissing(module) -> io.println(" +Module `" <> module <> "` doesn't have any public function I can use to bundle +a component. + +To bundle a component your module should have a public function that returns a +Lustre `App`: + + import lustre.{type App} + pub fn my_component() -> App(flags, model, msg) { + todo as \"your Lustre component to bundle\" + } +") + + MainMissing(module) -> io.println(" +Module `" <> module <> "` doesn't have a public `main` function I can use as +the bundle entry point.") + + ModuleMissing(module) -> io.println(" +I couldn't find a public module called `" <> module <> "` in your project.") -fn prepare_esbuild() -> Result(Nil, Error) { - esbuild.download(get_os(), get_cpu()) - |> result.replace_error(BuildError) + NameIncorrectType(module, type_) -> io.println(" +I can't use the `name` constant exposed by module `" <> module <> "` +to give a name to the component I'm bundling. +I was expecting `name` to be a `String`, +but it has type `" <> project.type_to_string(type_) <> "`.") + + NameMissing(module) -> io.println(" +Module `" <> module <> "` doesn't have a public `name` constant. +That is required so that I can give a proper name to the component I'm bundling. + +Try adding a `name` constant to your module like this: + + const name: String = \"component-name\"") + } } +// STEPS ----------------------------------------------------------------------- + fn get_project_name() -> Result(String, Error) { use config <- try(project.config(), replace(with: BuildError)) Ok(config.name) @@ -242,16 +271,11 @@ fn bundle( tempdir: String, outfile: String, minify: Bool, -) -> Result(Nil, Error) { +) -> Step(Nil, Error) { let entryfile = filepath.join(tempdir, "entry.mjs") let assert Ok(_) = simplifile.write(entryfile, entry) - - use _ <- try( - esbuild.bundle(entryfile, outfile, minify), - map(with: BundleError), - ) - - Ok(Nil) + use _ <- step.run(esbuild.bundle(entryfile, outfile, minify), BundleError) + step.return(Nil) } // UTILS ----------------------------------------------------------------------- @@ -288,11 +312,3 @@ fn is_compatible_app_type(t: Type) -> Bool { _ -> False } } - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "cli_ffi", "get_os") -fn get_os() -> String - -@external(erlang, "cli_ffi", "get_cpu") -fn get_cpu() -> String diff --git a/src/lustre/cli/dev.gleam b/src/lustre/cli/dev.gleam index f561b39..0cbec11 100644 --- a/src/lustre/cli/dev.gleam +++ b/src/lustre/cli/dev.gleam @@ -3,14 +3,14 @@ import filepath import gleam/dict import gleam/io -import gleam/result import gleam/string import glint.{type Command, CommandInput} import glint/flag import lustre/attribute.{attribute} import lustre/cli/esbuild import lustre/cli/project.{type Module, type Type, Fn, Named, Variable} -import lustre/cli/utils.{guard, map, replace, try} +import lustre/cli/step +import lustre/cli/utils.{guard, keep, map, replace, try} import lustre/element import lustre/element/html.{html} import simplifile @@ -28,14 +28,17 @@ pub fn run() -> Command(Nil) { let assert Ok(port) = flag.get_string(flags, "port") let assert Ok(include_styles) = flag.get_bool(flags, "include-styles") - let result = { - use interface <- try(project.interface(), replace(BuildError)) - use module <- try( + let script = { + use <- step.new("Building your project") + use interface <- step.try(project.interface(), replace(BuildError)) + use module <- step.try( dict.get(interface.modules, interface.name), replace(ModuleMissing(interface.name)), ) - use is_app <- result.try(check_is_lustre_app(interface.name, module)) + use is_app <- step.try(check_is_lustre_app(interface.name, module), keep) + use <- step.done("✅ Project compiled successfully") + use <- step.new("Creating the application entry point") let root = project.root() let tempdir = filepath.join(root, "build/.lustre") let _ = simplifile.create_directory_all(tempdir) @@ -50,7 +53,7 @@ pub fn run() -> Command(Nil) { " False -> " import { main } from '../dev/javascript/${app_name}/${app_name}.mjs'; - + main(); " } @@ -62,7 +65,7 @@ pub fn run() -> Command(Nil) { let assert Ok(_) = simplifile.write(tempdir <> "/entry.mjs", entry) let assert Ok(_) = simplifile.write(tempdir <> "/index.html", html) - use _ <- try( + use _ <- step.run( esbuild.bundle( filepath.join(tempdir, "entry.mjs"), filepath.join(tempdir, "index.mjs"), @@ -70,12 +73,11 @@ pub fn run() -> Command(Nil) { ), map(BundleError), ) - use _ <- try(esbuild.serve(host, port), map(BundleError)) - - Ok(Nil) + use _ <- step.run(esbuild.serve(host, port), map(BundleError)) + step.return(Nil) } - case result { + case step.execute(script) { Ok(_) -> Nil Error(error) -> explain(error) } @@ -120,9 +122,37 @@ type Error { } fn explain(error: Error) -> Nil { - error - |> string.inspect - |> io.println + case error { + BuildError -> project.explain(project.BuildError) + + BundleError(error) -> esbuild.explain(error) + + MainMissing(module) -> io.println(" +Module `" <> module <> "` doesn't have a public `main` function I can preview.") + + MainIncorrectType(module, type_) -> io.println(" +I cannot preview the `main` function exposed by module `" <> module <> "`. +To start a preview server I need it to take no arguments and return a Lustre +`App`. +The one I found has type `" <> project.type_to_string(type_) <> "`.") + + // TODO: maybe this could have useful links to `App`/flags... + MainBadAppType(module, type_) -> io.println(" +I cannot preview the `main` function exposed by module `" <> module <> "`. +To start a preview server I need it to return a Lustre `App` that doesn't need +any flags. +The one I found has type `" <> project.type_to_string(type_) <> "`. + +Its return type should look something like this: + + import lustre.{type App} + pub fn main() -> App(flags, model, msg) { + todo as \"your Lustre application to preview\" + }") + + ModuleMissing(module) -> io.println(" +I couldn't find a public module called `" <> module <> "` in your project.") + } } // STEPS ----------------------------------------------------------------------- diff --git a/src/lustre/cli/esbuild.gleam b/src/lustre/cli/esbuild.gleam index c98f5f3..bf824b2 100644 --- a/src/lustre/cli/esbuild.gleam +++ b/src/lustre/cli/esbuild.gleam @@ -3,54 +3,59 @@ import filepath import gleam/bool import gleam/dynamic.{type Dynamic} -import gleam/function import gleam/io import gleam/list -import gleam/pair import gleam/result import gleam/set import gleam/string import lustre/cli/project -import lustre/cli/utils.{keep, map, replace, try} +import lustre/cli/step.{type Step} +import lustre/cli/utils.{keep, replace} import shellout import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} // COMMANDS -------------------------------------------------------------------- -pub fn download(os: String, cpu: String) -> Result(Nil, Error) { +pub fn download(os: String, cpu: String) -> Step(Nil, Error) { + use <- step.new("Downloading esbuild") + let outdir = filepath.join(project.root(), "build/.lustre/bin") let outfile = filepath.join(outdir, "esbuild") - use <- bool.guard(check_esbuild_exists(outfile), Ok(Nil)) + use <- bool.guard(check_esbuild_exists(outfile), { + use <- step.done("✅ Esbuild already installed!") + step.return(Nil) + }) - io.println("\nInstalling esbuild...") - io.println(" ├ detecting platform") - use url <- result.try(get_download_url(os, cpu)) + use <- step.new("Detecting platform") + use url <- step.try(get_download_url(os, cpu), keep) - io.println(" ├ downloading from " <> url) - use tarball <- try(get_esbuild(url), NetworkError) + use <- step.new("Downloading from " <> url) + use tarball <- step.try(get_esbuild(url), NetworkError) - io.println(" ├ unpacking") - use bin <- try(unzip_esbuild(tarball), UnzipError) - use _ <- try(write_esbuild(bin, outdir, outfile), SimplifileError(_, outfile)) - use _ <- try(set_filepermissions(outfile), SimplifileError(_, outfile)) + use <- step.new("Unzipping esbuild") + use bin <- step.try(unzip_esbuild(tarball), UnzipError) + let write_esbuild = + write_esbuild(bin, outdir, outfile) + |> result.map_error(SimplifileError(_, outfile)) + use _ <- step.try(write_esbuild, keep) + use _ <- step.try(set_filepermissions(outfile), SimplifileError(_, outfile)) - io.println(" ├ installed!") - Ok(Nil) + use <- step.done("✅ Esbuild installed!") + step.return(Nil) } pub fn bundle( input_file: String, output_file: String, minify: Bool, -) -> Result(Nil, Error) { - use _ <- try(download(get_os(), get_cpu()), keep) - use _ <- try(project.build(), replace(BuildError)) +) -> Step(Nil, Error) { + use _ <- step.run(download(get_os(), get_cpu()), keep) + use _ <- step.try(project.build(), replace(BuildError)) + use <- step.new("Getting everything ready for tree shaking") - io.println("\nBundling with esbuild...") - io.println(" ├ configuring tree shaking") let root = project.root() - use _ <- try(configure_node_tree_shaking(root), map(SimplifileError(_, root))) + use _ <- step.try(configure_node_tree_shaking(root), SimplifileError(_, root)) let flags = [ "--bundle", @@ -63,40 +68,41 @@ pub fn bundle( False -> [input_file, ..flags] } - use _ <- try( + use <- step.new("Bundling with esbuild") + use _ <- step.try( shellout.command( run: "./build/.lustre/bin/esbuild", in: root, with: options, opt: [], ), - on_error: map(function.compose(pair.second, BundleError)), + on_error: fn(pair) { BundleError(pair.1) }, ) - io.println(" ├ bundle produced at " <> output_file) - Ok(Nil) + use <- step.done("✅ Bundle produced at `" <> output_file <> "`") + step.return(Nil) } -pub fn serve(host: String, port: String) -> Result(Nil, Error) { - use _ <- try(download(get_os(), get_cpu()), keep) +pub fn serve(host: String, port: String) -> Step(Nil, Error) { + use _ <- step.run(download(get_os(), get_cpu()), keep) let root = project.root() let flags = [ "--serve=" <> host <> ":" <> port, "--servedir=" <> filepath.join(root, "build/.lustre"), ] - io.println("\nStarting dev server at " <> host <> ":" <> port <> "...") - use _ <- try( + use <- step.done("\nStarting dev server at " <> host <> ":" <> port <> "...") + use _ <- step.try( shellout.command( run: "./build/.lustre/bin/esbuild", in: root, with: flags, opt: [], ), - on_error: map(function.compose(pair.second, BundleError)), + on_error: fn(pair) { BundleError(pair.1) }, ) - Ok(Nil) + step.return(Nil) } // STEPS ----------------------------------------------------------------------- @@ -197,9 +203,37 @@ pub type Error { } pub fn explain(error: Error) -> Nil { - error - |> string.inspect - |> io.println + case error { + BuildError -> project.explain(project.BuildError) + + BundleError(message) -> io.println(" +I ran into an error while trying to create a bundle with esbuild: +" <> message) + + // TODO: Is there a better way to deal with this dynamic error? + NetworkError(_dynamic) -> + io.println( + " +There was a network error!", + ) + + // TODO: this could give a better error for some common reason like Enoent. + SimplifileError(reason, path) -> io.println(" +I ran into the following error at path `" <> path <> "`:" <> string.inspect( + reason, + ) <> ".") + + UnknownPlatform(os, cpu) -> io.println(" +I couldn't figure out the correct esbuild version for your +os (" <> os <> ") and cpu (" <> cpu <> ").") + + // TODO: Is there a better way to deal with this dynamic error? + UnzipError(_dynamic) -> + io.println( + " +I couldn't unzip the esbuild executable!", + ) + } } // EXTERNALS ------------------------------------------------------------------- diff --git a/src/lustre/cli/project.gleam b/src/lustre/cli/project.gleam index 68b5801..8d04244 100644 --- a/src/lustre/cli/project.gleam +++ b/src/lustre/cli/project.gleam @@ -3,8 +3,10 @@ import filepath import gleam/dict.{type Dict} import gleam/dynamic.{type DecodeError, type Dynamic, DecodeError} +import gleam/int import gleam/io import gleam/json +import gleam/list import gleam/pair import gleam/result import gleam/string @@ -72,7 +74,6 @@ pub fn interface() -> Result(Interface, String) { let assert Ok(json) = simplifile.read(out) let assert Ok(interface) = json.decode(json, decode_interface) - Ok(interface) } @@ -106,9 +107,13 @@ pub type Error { } pub fn explain(error: Error) -> Nil { - error - |> string.inspect - |> io.println + case error { + BuildError -> + " +It looks like your project has some compilation errors that need to be addressed +before I can do anything." + |> io.println + } } // UTILS ----------------------------------------------------------------------- @@ -129,6 +134,29 @@ fn find_root(path: String) -> String { } } +pub fn type_to_string(type_: Type) -> String { + case type_ { + Tuple(elements) -> { + let elements = list.map(elements, type_to_string) + "#(" <> string.join(elements, with: ", ") <> ")" + } + + Fn(params, return) -> { + let params = list.map(params, type_to_string) + let return = type_to_string(return) + "fn(" <> string.join(params, with: ", ") <> ") -> " <> return + } + + Named(name, _package, _module, []) -> name + Named(name, _package, _module, params) -> { + let params = list.map(params, type_to_string) + name <> "(" <> string.join(params, with: ", ") <> ")" + } + + Variable(id) -> "a_" <> int.to_string(id) + } +} + // DECODERS -------------------------------------------------------------------- fn decode_interface(dyn: Dynamic) -> Result(Interface, List(DecodeError)) { diff --git a/src/lustre/cli/step.gleam b/src/lustre/cli/step.gleam new file mode 100644 index 0000000..d3f8c8a --- /dev/null +++ b/src/lustre/cli/step.gleam @@ -0,0 +1,100 @@ +import gleam/io +import gleam_community/ansi +import spinner.{type Spinner} + +type SpinnerStatus { + Running(message: String) + Stopped +} + +type Env { + Env(spinner: Spinner, spinner_status: SpinnerStatus) +} + +pub opaque type Step(a, e) { + Step(run: fn(Env) -> #(Env, Result(a, e))) +} + +/// Replace the current spinner label with a new one. +/// +pub fn new(message: String, then continue: fn() -> Step(a, e)) -> Step(a, e) { + use Env(spinner, spinner_status) <- Step + case spinner_status { + Running(_) -> { + spinner.set_text(spinner, message) + continue().run(Env(spinner, Running(message))) + } + Stopped -> { + let new_spinner = + spinner.new(message) + |> spinner.with_frames(spinner.snake_frames) + |> spinner.start + continue().run(Env(new_spinner, Running(message))) + } + } +} + +/// Stops the current spinner and prints out the given message. +/// +pub fn done(message: String, then continue: fn() -> Step(b, e)) -> Step(b, e) { + use Env(spinner, spinner_status) <- Step + case spinner_status { + Running(_) -> spinner.stop(spinner) + Stopped -> Nil + } + io.println(ansi.green(message)) + continue().run(Env(spinner, Stopped)) +} + +/// Runs another step as part of this one. The step will use the same spinner +/// as the previous one overriding its content. +/// +pub fn run( + step: Step(a, e), + on_error map_error: fn(e) -> e1, + then continue: fn(a) -> Step(b, e1), +) -> Step(b, e1) { + use env <- Step + case step.run(env) { + #(new_env, Ok(res)) -> continue(res).run(new_env) + #(new_env, Error(e)) -> #(new_env, Error(map_error(e))) + } +} + +/// If the result is `Ok` will continue by passing its wrapped value to the +/// `continue` function; otherwise will result in an error stopping the stepwise +/// execution. +/// +pub fn try( + result: Result(a, e), + on_error map_error: fn(e) -> e1, + then continue: fn(a) -> Step(b, e1), +) -> Step(b, e1) { + Step(fn(env) { #(env, result) }) + |> run(map_error, continue) +} + +/// Returns a value without changing the state of any spinner. +/// Any running spinner will still be running. +/// +pub fn return(value: a) -> Step(a, e) { + use env <- Step + #(env, Ok(value)) +} + +pub fn execute(step: Step(a, e)) -> Result(a, e) { + let initial_spinner = + spinner.new("") + |> spinner.with_frames(spinner.snake_frames) + |> spinner.start + + let #(Env(spinner, status), res) = step.run(Env(initial_spinner, Running(""))) + case status { + Running(message) -> { + spinner.stop(spinner) + io.println("❌ " <> ansi.red(message)) + } + Stopped -> Nil + } + res +} |