//// This module provides Kindly's main API for defining, selecting, and running //// project tasks through a `Handbook`. //// //// When run as an app, Kindly finds the current project's `Handbook`, or //// interactively offers to create a new one, and runs selected tasks from it. //// import gleam/bool import gleam/dict import gleam/int import gleam/io import gleam/javascript/promise import gleam/list import gleam/option.{type Option, None, Some} import gleam/order.{Gt, Lt} import gleam/result import gleam/string import kindly/set.{type Set} import string_width // ⬤ •╱ *╱. // ˙ ‧╱╱ // ╱╲*.· // ╲╱╲ // ꟸ ╲ ╲ Configuration // .˙‧∴.˙∵‧ // Ψ˙ ≁ ˙ // ↀ ʽ ⍦ // ⍭ ’ ‚ // ‟╷│ // ’ ╰┼╯ „ // ‟ // Static arguments const end_flag_doc = "Pass further arguments to all selected tasks" const help_flag_doc = "Print help information" const new_flag_doc = "Make a new handbook" const cue_flag_doc = "Print shell completions" const handbook_flags = [ #("-- ", end_flag_doc), #("--any ", "Filter tasks for any of the following tags"), #("--help", help_flag_doc), #("--time", "Print time elapsed"), #("--new ", new_flag_doc), #("--cue ", cue_flag_doc), ] const blank_handbook_flags = [ #("--help", help_flag_doc), #("--new ", new_flag_doc), #("--cue ", cue_flag_doc), ] const cue_flags = [ #("-- ", "Print completions for further arguments"), #("--help", "Print help information for completions"), ] const cue_shells = [ #("bash", "Print a bash completion script"), #("fish", "Print a fish completion script"), #("pwsh", "Print a pwsh completion script"), #("zsh ", "Print a zsh completion script"), ] // Formatting const nbsp = "\u{a0}" const is_that_ok_ = " Is" <> nbsp <> "that" <> nbsp <> "OK?" /// The default line width after which Kindly wraps words to the next line. /// /// Can be overridden with the environment variable `KINDLY_MAX_WIDTH`. /// const max_width = 64 /// Minimum terminal width Kindly attempts to support when formatting output. /// const min_width = 16 /// Smallest tab allowed. /// const min_tab = " " // ANSI codes const bold = 1 const dim = 2 const italic = 3 const underline = 4 // ⬤ •╱ *╱. // ˙ ‧╱╱ // ╱╲*.· // ╲╱╲ // ꟸ ╲ ╲ Application // .˙‧∴.˙∵‧ // Ψ˙ ≁ ˙ // ↀ ʽ ⍦ // ⍭ ’ ‚ // ‟╷│ // ’ ╰┼╯ „ // ‟ /// A JavaScript `Promise`, re-exported from `gleam_javascript` for convenience. /// /// For further information view the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). /// pub type Promise(a) = promise.Promise(a) /// A type that never returns. /// @external(javascript, "./kindly_ffi.ts", "Never") pub type Never /// Finds a `Handbook` or interactively tries to write a new one. /// @external(erlang, "kindly", "panic_from_erlang_main") @internal pub fn main() -> Promise(Never) { use Args(cue:, help:, new:, ..) as args <- exit_unless( is_ok: args() |> parse_args(from: None) |> promise.resolve, ) let can_interact = [Stdin, Stdout, Stderr] |> list.all(is_terminal) let print_info = fn() { info(apply: default_theme()) |> string.join(with: "\n") |> io.println } use <- bool.lazy_guard(when: new, return: fn() { // TODO: --new --help let _ = help case can_interact { True -> { print_info() use result <- promise.await(do_new()) exit(code: case result { Ok(_) -> 0 _else -> 1 }) } False -> { "`--new` needs a terminal for `stdin`, `stdout`, and `stderr`" |> print_error exit(code: 1) } } }) use result <- promise.await(get_handbook()) case result { Ok(run_handbook) -> run_handbook() Error(message) if message != "no handbook to run" -> { message |> print_error exit(code: 1) } _ if cue != None -> exit(code: case run_cue(with: args, from: "blank" |> handbook) { Ok(_) -> 0 _else -> 1 }) _ if help -> { let theme = default_theme() [ info(apply: theme), [""], usage(with: "", apply: theme), [""], flags(with: blank_handbook_flags, apply: theme), ] |> list.flatten |> string.join(with: "\n") |> io.println exit(code: 0) } _ if can_interact -> { print_info() use should_write_handbook <- exit_unless( is_ok: { "It looks like you’ll need to make a new handbook." <> is_that_ok_ } |> get_bool(or: True), ) use <- bool.lazy_guard(when: not_(should_write_handbook), return: fn() { { "\n" <> "No worries. Farewell~" |> wrap_line(with: "") } |> io.println exit(code: 0) }) use result <- promise.await(do_new()) exit(code: case result { Ok(_) -> 0 _else -> 1 }) } _else -> { "no handbook to run" |> print_error exit(code: 1) } } } /// Fails loudly when attempting to run Kindly's CLI via /// `gleam run -t erlang -m kindly`. /// @internal pub fn panic_from_erlang_main() -> Never { panic as "Kindly’s CLI runs with JavaScript.\n\nUse the `kindly` command from npm or jsr instead of `gleam run -t erlang -m kindly`." } /// A type for holding info about the arguments given to the program. /// type Args { Args( // Primary fields cue: Option(Option(String)), help: Bool, new: Bool, time: Bool, all_tags: Set(String), any_tags: Set(String), action_args: List(String), // Secondary fields has_tags: Bool, has_any: Bool, has_end: Bool, errors: Set(String), ) } /// Returns `Args` based on the `List` of arguments given when invoking the /// program. /// /// Everything before the `--` flag is treated as Kindly tags or flags. /// /// Everything after the `--` flag is treated as arguments to pass along to all /// selected tasks. /// /// Tags are grouped by whether they come before or after the `--any` flag. /// fn parse_args( args: List(String), from handbook: Option(Handbook), ) -> Result(Args, Nil) { // TODO: mv to validate_handbook fn? let #(tags, tag_errors, doc_errors) = case handbook { Some(Handbook(tasks:, ..)) -> { use #(tags, tag_errors, doc_errors), task <- list.fold( over: tasks, from: #(Some(set.new()), set.new(), 0), ) let tags = tags |> option.map(with: set.union(of: _, and: task.tags)) let tag_errors = { use tag_errors, tag <- list.fold( over: task.tags |> set.to_list, from: tag_errors, ) case not_(tag |> tag_is_valid) { True -> tag_errors |> set.insert(tag) False -> tag_errors } } let doc_errors = case task.doc |> string.contains("\n") || task.doc |> string.contains("\r") { True -> doc_errors + 1 False -> doc_errors } #(tags, tag_errors, doc_errors) } None -> #(None, set.new(), 0) } let errors = [ case tag_errors |> set.to_list { [] -> [] tag_errors -> [ [ "handbook tags can’t start with `-`, contain blank space, or be empty", "choose different tags for: " <> { tag_errors |> list.map(with: fn(x) { "`" <> x <> "`" }) |> string.join(with: ", ") }, ], ] }, case doc_errors { 0 -> [] x -> [ [ "handbook docs can’t contain `\\n` or `\\r`", "revise docs for " <> int.to_string(x) <> " of your tasks", ], ] }, ] |> list.flatten |> list.map(with: string.join(_, with: "\n")) case errors { [] -> Args( // Primary fields cue: None, help: False, new: False, time: False, all_tags: set.new(), any_tags: set.new(), action_args: [], // Secondary fields has_tags: False, has_any: False, has_end: False, errors: set.new(), ) |> parse_args_loop(with: args, compared_to: tags) _else -> errors |> list.each(print_error) |> Error } } fn parse_args_loop( acc: Args, with args: List(String), compared_to tags: Option(Set(String)), ) -> Result(Args, Nil) { let Args(all_tags:, any_tags:, has_any:, has_end:, ..) = acc case args { [_, ..] if has_end -> // Take remaining args Args(..acc, action_args: args) |> parse_args_loop(with: [], compared_to: tags) ["--cue", ..args] -> case args { [] | ["-" <> _, ..] -> Args(..acc, cue: Some(None)) |> parse_args_loop(with: args, compared_to: tags) [arg, ..args] -> Args(..acc, cue: Some(Some(arg))) |> parse_args_loop(with: args, compared_to: tags) } [arg, ..args] -> case arg { "--" -> Args(..acc, has_end: True) "--any" -> Args(..acc, has_any: True) "--cue=" <> arg -> Args(..acc, cue: Some(Some(arg))) "--help" -> Args(..acc, help: True) "--new" -> Args(..acc, new: True) "--time" -> Args(..acc, time: True) tag -> { let error = fn(message) { let message = "tag `" <> tag <> "` " <> message Args(..acc, errors: acc.errors |> set.insert(message)) } // TODO: replace with unknown_flag verbiage? let invalid_tag = not_(tag |> tag_is_valid) let missing_tag = case tags { Some(tags) -> not_(tags |> set.contains(tag)) None -> False } let acc = case tag { _ if invalid_tag -> "can’t start with `-`, contain blank space, or be empty" |> error _ if missing_tag -> "isn’t in your handbook" |> error _else -> acc } case tag { tag if has_any -> Args(..acc, any_tags: any_tags |> set.insert(tag), has_tags: True) tag -> Args(..acc, all_tags: all_tags |> set.insert(tag), has_tags: True) } } } |> parse_args_loop(with: args, compared_to: tags) // Done [] -> acc |> validate_args } } /// Results in the given `Args` if no conflicting arguments are present, or /// `Nil` on failure, after printing all found errors. /// fn validate_args(args: Args) -> Result(Args, Nil) { let Args(cue:, help:, new:, time:, action_args:, ..) = args let Args(has_tags:, has_any:, has_end:, errors:, ..) = args let errors = errors |> set.to_list let errors = [ case cue { Some(_) if has_tags || has_any || new || time -> [ "can’t run `--cue` with other flags or tags", ] Some(None) if !help && !has_end -> [ "can’t run `--cue` without any arguments", ] _else -> [] }, case cue { Some(Some(x)) if x != "bash" && x != "fish" && x != "pwsh" && x != "zsh" -> { let shells = "`bash`, `fish`, `pwsh`, or `zsh`" ["can’t run `--cue` for `" <> x <> "`, try " <> shells] } _else -> [] }, case new { // TODO: brackets aren't needed, but Gleam v1.14.0 generates faulty JS otherwise True if { has_tags || has_any || cue != None || time || action_args != [] } -> ["can’t run `--new` with other arguments"] _else -> [] }, errors, ] |> list.flatten case errors { [] -> args |> Ok _else -> errors |> list.each(print_error) |> Error } } /// A starter handbook module for Gleam. /// const gleam_handbook = "import kindly.{type Handbook, task} pub fn main() -> Handbook { kindly.handbook(for: \"{{project}}\") |> task( doc: \"Print a greeting\", tags: [\"hi\"], action: kindly.just(run: \"echo\", with: [\"Welcome to your new handbook!\"]), ) } " /// A starter handbook module for JavaScript. /// const javascript_handbook = "import kindly from \"{{module}}\"; export default kindly.handbook({ for: \"{{project}}\" }) .task({ doc: \"Print a greeting\", tags: [\"hi\"], action: kindly.just(\"echo\", \"Welcome to your new handbook!\"), }); " /// Results in the path to a new starter handbook module. Fails if the file /// can't be written. /// fn do_new() -> Promise(Result(Nil, Nil)) { let dir = project_root() let print_error = fn(message) { "" |> io.println_error message |> print_error } use should_use_dir <- promise.try_await( { "It looks like " <> file(dir) <> " is your project’s root directory." <> is_that_ok_ } |> get_bool(or: True), ) use dir <- promise.try_await(case should_use_dir { True -> dir |> Ok |> promise.resolve False -> "What is your project’s root directory?" |> get_line(or: "") }) use <- bool.lazy_guard(when: dir == "", return: fn() { "can’t proceed without a project root directory" |> print_error |> Error |> promise.resolve }) let dir = case dir |> path_is_absolute { True -> dir False -> [current_directory(), dir] |> path_join } use dir_is_readable <- promise.await(dir |> file_is_readable) use <- bool.lazy_guard(when: not_(dir_is_readable), return: fn() { "project root directory is unreadable" |> print_error |> Error |> promise.resolve }) use _ <- promise.await(dir |> set_gleam_project) use project <- promise.try_await(case gleam_project() { "" -> "What is your project’s name?" |> get_line(or: "new project") name -> name |> Ok |> promise.resolve }) use should_use_gleam <- promise.try_await(case gleam_project() { "" -> False |> Ok |> promise.resolve _else -> { "It looks like you can write your handbook in " <> gleam() <> "." <> is_that_ok_ } |> get_bool(or: True) }) use should_use_typescript <- promise.try_await(case should_use_gleam { True -> False |> Ok |> promise.resolve False -> { "It looks like you can write your handbook in " <> typescript() <> "." <> is_that_ok_ } |> get_bool(or: True) }) use has_handbookjs <- promise.await( [dir, "handbook.js"] |> path_join |> file_is_readable, ) let path = case should_use_gleam { True -> [dir, "dev", "handbook.gleam"] False if should_use_typescript -> [dir, "handbook.ts"] False if has_handbookjs -> [dir, "handbook.js"] False -> [dir, "handbook.mjs"] } |> path_join use path_is_readable <- promise.await(path |> file_is_readable) use should_write <- promise.try_await(case path_is_readable { True -> { "The file " <> file(path) <> " already exists." <> overwrite_() } |> get_bool(or: False) False -> True |> Ok |> promise.resolve }) use <- bool.lazy_guard(when: not_(should_write), return: fn() { "can’t proceed without overwriting existing file" |> print_error |> Error |> promise.resolve }) let should_use_deno = runtime_is_deno() use success <- promise.await( case should_use_gleam { True -> gleam_handbook False if should_use_deno -> javascript_handbook |> string.replace(each: "{{module}}", with: "jsr:@tynanbe/kindly@1") False -> javascript_handbook |> string.replace(each: "{{module}}", with: "@tynanbe/kindly") } |> string.replace(each: "{{project}}", with: project) |> file_write(to: path, mode: 0o644, force: True), ) use <- bool.lazy_guard(when: success != Ok(True), return: fn() { "failed writing handbook" |> print_error |> Error |> promise.resolve }) let green = 32 [ "", "Success!" |> ansi(apply: [bold, green]), "", "Your new handbook module is " <> file(path), // TODO: install kindly? already done if running? node_options, runtime ] |> string.join(with: "\n") |> io.println |> Ok |> promise.resolve } /// Promises to result in a `Bool` that depends on the given default and the /// user's response to the given prompt. /// /// If the default is `True`, the string `" (Y/n)"` is appended to the given /// prompt and the `Result` is `True` for any answer that doesn't start with /// `"n"` (case-insensitive). /// /// If the default is `False`, the string `" (y/N)"` is appended to the given /// prompt and the `Result` is only `True` for any answer that start with `"y"` /// (case-insensitive). Note that this is intentionally more restrictive than /// the previous case. /// fn get_bool( prompt prompt: String, or default: Bool, ) -> Promise(Result(Bool, Nil)) { use answer <- promise.map_try(case default { True -> { prompt <> nbsp <> "(Y/n)" } |> get_line(or: "y") False -> { prompt <> nbsp <> "(y/N)" } |> get_line(or: "n") }) answer |> string.trim |> string.lowercase |> case default { True -> fn(x) { not_(x |> string.starts_with("n")) } False -> string.starts_with(_, "y") } |> Ok } /// Prints the given `prompt` and tries to read the user's response after a /// newline is sent. /// /// If the response is empty, it promises to result in the given default /// `String` instead. /// /// Promises to result in an `Error` when EOT is read. /// fn get_line( prompt prompt: String, or default: String, ) -> Promise(Result(String, Nil)) { { "\n" <> prompt |> wrap_line(with: "") } |> do_get_line(or: default) } @external(javascript, "./kindly_ffi.ts", "get_line") fn do_get_line( prompt prompt: String, or default: String, ) -> Promise(Result(String, Nil)) /// Styles the given file for printing. /// fn file(file: String) { let blue = 34 "`" <> { file |> ansi(apply: [bold, blue]) } <> "`" } /// Returns the word `"Gleam"`, styled for printing. /// fn gleam() { let pink = [38, 5, 219] "Gleam" |> ansi(apply: [bold, ..pink]) } /// Returns the word `"TypeScript"`, styled for printing. /// fn typescript() { let blue = 34 "TypeScript" |> ansi(apply: [bold, blue]) } /// Returns the question `"Overwrite?"` on a new line, styled for printing. /// fn overwrite_() -> String { let red = 31 "\n" <> { "Overwrite?" |> ansi(apply: [bold, red]) } } /// Runs a `Handbook` with arguments from the shell. /// /// Exits the current process with status code `0` for valid help displays or /// when all selected tasks succeed; otherwise, exits with status code `1`. /// fn run_handbook(handbook: Handbook) -> Promise(Never) { use Args(time:, has_tags:, ..) as args <- exit_unless( is_ok: args() |> parse_args(from: Some(handbook)) |> promise.resolve, ) let should_help = args.help || not_(has_tags) let handbook = Handbook(..handbook, tasks: { use task <- list.map(handbook.tasks) Task( ..task, group_doc: task.group_doc |> option.map(with: string.trim), doc: task.doc |> string.trim, ) }) use <- bool.lazy_guard(when: args.cue != None, return: fn() { exit(code: case run_cue(with: args, from: handbook) { Ok(_) -> 0 _else -> 1 }) }) let tasks = { use Task(tags:, ..) <- list.filter(handbook.tasks) args.all_tags |> set.is_subset(of: tags) && { { has_tags && args.any_tags |> set.is_empty } || not_(args.any_tags |> set.is_disjoint(from: tags)) } } use <- bool.lazy_guard(when: tasks == [] && not_(should_help), return: fn() { "no tasks to run" |> print_error exit(code: 1) }) let timestamp = now() use success <- promise.await(case should_help { True -> tasks |> help_handbook(with: args, from: handbook) False -> tasks |> run_tasks(with: args, from: handbook) }) case time { True -> print_duration(since: timestamp, apply: handbook.theme) False -> Nil } exit(code: case success { Ok(_) -> 0 Error(_) -> 1 }) } /// Runs when `--cue` is given as a valid argument. Generates dynamic /// completions for arguments given after the end flag (`--`), or a static /// completion helper script for the given shell otherwise. Also handles /// `--help --cue` output. /// fn run_cue(with args: Args, from handbook: Handbook) -> Result(Nil, Nil) { let Args(cue:, help:, has_end:, ..) = args case cue { _ if help -> help_cue(cue |> option.unwrap(or: None), apply: handbook.theme) |> Ok Some(maybe_shell) if has_end -> do_cue(maybe_shell, with: args, from: handbook) Some(Some(shell)) -> completion_script(for: shell) |> result.map(with: io.println) _else -> Nil |> Ok } } /// Prints help text for the `--cue` flag. /// fn help_cue(maybe_shell: Option(String), apply theme: Theme) -> Nil { let shell = case maybe_shell { Some(shell) -> shell None -> "shell?" } [ info(apply: theme), [""], usage( with: theme.flag("--cue") <> " " <> theme.parameter(shell), apply: theme, ), [""], description( [ "Print tab completions for the given shell.", "When no end flag (`--`) is given, a script is printed for the given shell to obtain completions from `kindly`.", "With an end flag, any further arguments are seen as what would be given to `kindly`, and used to generate completions for the given shell.", "If the end flag is given without a shell, default output is printed with an argument and description per line, separated by a tab.", ], apply: theme, ), [""], flags(with: cue_flags, apply: theme), [""], ["Shells" |> theme.heading], { use #(name, doc) <- list.map(cue_shells) let blank_name = string.repeat(" ", times: safe_width(of: name)) let margin = theme.tab <> blank_name <> theme.tab case not_(viewport_width_is_narrow(for: margin)) { True -> { theme.tab <> theme.parameter(name) <> theme.tab <> doc } |> wrap_line(with: margin) False -> { "\n" <> theme.parameter(name) <> "\n" } <> { min_tab <> doc } |> wrap_line(with: min_tab) } }, ] |> list.flatten |> string.join(with: "\n") |> io.println } /// Generates and prints dynamic completions for arguments given after the end /// flag (`--`), to be consumed by the given shell, defaulting to the /// tab-delimited format consumed by e.g. `fish`. /// fn do_cue( maybe_shell: Option(String), with args: Args, from handbook: Handbook, ) -> Result(Nil, Nil) { use #(args, current, prev) <- result.try( case args.action_args |> list.reverse { [current, prev, ..args] -> #([prev, ..args] |> list.reverse, current, Some(prev)) |> Ok [current] -> #([], current, None) |> Ok [] -> [ "can’t run `--cue --` without any further arguments", "hint: an empty string '' should represent a blank, incomplete word", ] |> string.join(with: "\n") |> print_error |> Error }, ) let #(args, current, prev, has_eq) = { case current |> string.split_once(on: "=") { Ok(#("--" <> _ as prev, current)) -> #( args |> list.append([prev]), current, Some(prev), True, ) _else -> #(args, current, prev, False) } } let #(args, should_complete_cue) = case prev { // Make `--cue` temporarily pass `validate_args` Some("--cue") -> #(args |> list.append(["bash"]), True) _else -> #(args, False) } use Args(cue:, help:, new:, time:, ..) as args <- result.try( args |> parse_args(from: Some(handbook)), ) let Args(has_any:, has_end:, has_tags:, ..) = args use <- bool.lazy_guard(when: help && { cue != None || new }, return: fn() { "can’t accept any further arguments" |> print_error |> Error }) use <- bool.lazy_guard(when: has_end, return: fn() { Ok(Nil) }) let terminal_width = terminal_width() let handbook_flags = case handbook.tasks { [] -> blank_handbook_flags _else -> handbook_flags } let #(lines, arg_width, line_width) = { use #(lines, arg_width, line_width) as acc, #(arg, doc) <- list.fold( from: #([], 0, 0), over: case current { _ if cue != None -> [cue_shells, cue_flags] |> list.flatten "-" <> _ -> handbook_flags _else -> case handbook.tasks |> cue_tags(with: args) { [] -> handbook_flags tags -> tags |> list.append([#("--", end_flag_doc)]) } }, ) let arg = arg |> string.trim let should_discard = case arg { // Help "--help" if has_eq -> True "--help" -> help // New _ if new -> True // Flags "--" -> has_eq // TODO: discard if no more tags? "--any" -> has_any "--cue" -> has_tags || has_any || time "--new" -> has_tags || has_any || cue != None || time "--time" -> time // Cue _ if cue != None && !should_complete_cue -> True _else -> False } // TODO: keep fuzzy matches? || not_(arg |> string.starts_with(current)) use <- bool.guard(when: should_discard, return: acc) let arg = case prev { Some(prev) if has_eq && maybe_shell != Some("bash") -> prev <> "=" <> arg _else -> arg } let arg_width = arg |> safe_width |> int.max(arg_width) let line_width = { arg <> " (" <> doc <> ")" } |> common_width |> int.max(line_width) |> int.min(terminal_width) #([#(arg, doc), ..lines], arg_width, line_width) } let should_doc = case lines { [_, _, ..] if line_width - arg_width > 19 -> True _else -> False } let lines = { use #(arg, doc) <- list.map(lines |> list.reverse) let line = case maybe_shell { Some("bash") | Some("pwsh") if should_doc -> { let arg_width = arg |> common_width let doc_width = line_width - arg_width let doc = " (" <> doc <> ")" case safe_width(of: doc) > doc_width { True -> arg <> truncate_width(doc, to: doc_width - 2) <> "…)" False -> arg <> pad_width_left(doc, to: doc_width) } } Some("zsh") if should_doc -> { let escape = string.replace(each: ":", with: "\\:", in: _) escape(arg) <> ":" <> escape(doc) } _else if should_doc -> arg <> "\t" <> doc _else -> arg } case maybe_shell { Some("pwsh") -> arg <> "\t" <> line <> "\t" <> doc _else -> line } } case lines { [_, ..] -> lines |> string.join(with: "\n") |> io.println |> Ok _else -> "no more completions" |> print_error |> Error } } /// Returns tags that can be added via command completion based on the given /// args. /// fn cue_tags(tasks: List(Task), with args: Args) -> List(#(String, String)) { let given_tags = args.all_tags |> set.union(args.any_tags) let has_tags = not_(args.all_tags |> set.is_empty) let available_tasks = { use Task(tags:, ..) <- list.filter(tasks) args.all_tags |> set.is_subset(of: tags) && args.any_tags |> set.is_disjoint(from: tags) } let tasks = case available_tasks { [] if has_tags -> [] [] -> tasks tasks -> tasks } let #(firsts, rests) = { use #(firsts, rests), Task(tags:, group_doc:, doc:, ..) <- list.fold( over: tasks, from: #(dict.new(), dict.new()), ) case tags |> set.to_list { [first, ..rest] -> { let firsts = case given_tags |> set.contains(first) { True -> firsts False -> { use tag <- dict.upsert(in: firsts, update: first) case tag { Some(#(doc, x)) -> #(doc, x + 1) None -> #(group_doc |> option.unwrap(or: doc), 1) } } } let rests = { use rests, tag <- list.fold(over: rest, from: rests) case given_tags |> set.contains(tag) { True -> rests False -> { use tag <- dict.upsert(in: rests, update: tag) case tag { Some(#(_, x)) -> #("", x + 1) None -> #(doc, 1) } } } } #(firsts, rests) } [] -> #(firsts, rests) } } let rests = rests |> dict.filter(keeping: fn(x, _) { not_(firsts |> dict.has_key(x)) }) let firsts = { use #(tag, #(doc, count)) <- list.map(firsts |> dict.to_list) let count = case count { 1 -> "" _else -> ": " <> int.to_string(count) <> " tasks" } #(tag, doc <> count) } let rests = { use #(tag, #(doc, count)) <- list.map(rests |> dict.to_list) let info = case count { 1 -> doc _else -> int.to_string(count) <> " tasks" } #(tag, info) } [firsts, rests] |> list.map( with: list.sort(_, by: fn(a, b: #(String, _)) { string.compare(a.0, b.0) }), ) |> list.flatten } /// Sequentially runs the action function for each selected `Task`. /// /// Prints a heading, and summary of failed tasks when multiple tasks are run. /// /// Each task's action function is called with the parsed action arguments and /// the `Handbook`. /// fn run_tasks( run tasks: List(Task), with args: Args, from handbook: Handbook, ) -> TaskResult { let theme = handbook.theme let kindly = "Kindly" |> theme.highlight |> ansi(apply: [italic]) let running_tasks_for = case tasks |> list.length |> int.to_string { "1" -> "a task" x -> x <> " tasks" } |> fn(x) { " running " <> x <> " for " } |> theme.heading |> ansi(apply: [italic]) let name = handbook.name |> theme.highlight |> ansi(apply: [italic]) // Print main heading { kindly <> running_tasks_for <> name } |> wrap_line(with: "") |> io.println // Run tasks use #(tried, failed) <- promise.map( #(0, 0) |> run_tasks_loop(for: tasks, with: args.action_args, from: handbook), ) let summarise_failures = fn() { "" |> io.println_error case tried |> int.to_string, failed |> int.to_string { "2", "2" -> "both tasks failed" tried, failed if tried == failed -> "all " <> tried <> " tasks failed" tried, failed -> failed <> " of " <> tried <> " tasks failed" } |> print_error } case failed { 0 -> Ok(Nil) _else if tried > 1 -> summarise_failures() |> Error _else -> Error(Nil) } } fn run_tasks_loop( acc: #(Int, Int), for tasks: List(Task), with args: List(String), from handbook: Handbook, ) -> Promise(#(Int, Int)) { case tasks { [Task(doc:, tags:, action:, ..), ..tasks] -> { let theme = handbook.theme let #(tried, failed) = acc let tried = tried + 1 let heading = case tasks { [] if tried == 1 -> "" _else -> " " <> int.to_string(tried) } |> fn(x) { "Task" <> x <> ":" } |> theme.heading |> ansi(apply: [italic]) let doc = case doc { "" -> case tags |> set.to_list { [tag, ..] -> " " <> string.capitalise(tag) _else -> " Unknown" } _else -> " " <> doc } |> ansi(apply: [italic]) let heading = { heading <> doc } |> wrap_line(with: "") // Print `Task` heading { "\n" <> heading } |> io.println // Run `Task` use result <- promise.await(case change_directory(to: project_root()) { Ok(_) -> fn() { action(args, handbook.tasks) } |> rescue Error(_) -> reject() }) case result { Ok(_) -> #(tried, failed) Error(_) -> { "task failed" |> print_error #(tried, failed + 1) } } |> run_tasks_loop(for: tasks, with: args, from: handbook) } // Done [] -> acc |> promise.resolve } } /// Prints help information for the given tasks. /// fn help_handbook( for tasks: List(Task), with args: Args, from handbook: Handbook, ) -> TaskResult { let #(sections, success) = case tasks { [] -> #(handbook |> handbook_menu, not_(args.has_tags)) _else -> #(tasks |> handbook_plan(with: args, from: handbook), True) } let sections = sections |> list.flatten |> string.join(with: "\n") case success { True -> io.println(sections) |> Ok False -> { "no tasks to run\n" |> print_error io.println_error(sections) |> Error } } |> promise.resolve } /// Returns printable sections for the full `Handbook` help menu. /// fn handbook_menu(from handbook: Handbook) -> List(List(String)) { let theme = handbook.theme let name = handbook.name |> theme.highlight [ info(apply: theme), [""], usage(with: "...tags?" |> theme.tag, apply: theme), [""], flags(with: handbook_flags, apply: theme), [""], [ { "Task menu for " <> name } |> theme.heading |> wrap_line(with: ""), ], handbook |> menu_tasks, ] } /// Returns help menu lines. /// /// Each menu item has a heading, possibly a line of tags shared by all item /// tasks, and lines for tags specific to each task. /// fn menu_tasks(from handbook: Handbook) -> List(String) { let theme = handbook.theme let menu_items = [] |> menu_tasks_loop(from: handbook.tasks) use <- bool.guard(when: menu_items == [], return: [ theme.tab <> "None. The handbook is empty!", ]) let tag_width = { use acc, item <- list.fold(over: menu_items, from: 0) item.tag |> safe_width |> int.max(acc) } let margin = theme.tab <> string.repeat(" ", times: tag_width) <> theme.tab use MenuItem(tasks:, tag:, doc:) <- list.flat_map(over: menu_items) let count = case tasks |> list.length |> int.to_string { "1" -> "" x -> { " (" <> x <> ")" } |> theme.heading } let heading = tag |> pad_width_right(to: tag_width) |> theme.first_tag |> theme.tag |> fn(x) { case not_(viewport_width_is_narrow(for: margin)) { True -> { theme.tab <> x <> theme.tab <> doc <> count } |> wrap_line(with: margin) False -> { "\n" <> x <> "\n" } <> { min_tab <> doc <> count } |> wrap_line(with: min_tab) } } let margin = margin <> theme.tab let margin = case not_(viewport_width_is_narrow(for: margin)) { True -> margin False -> "" } let tag_items = { use tags <- list.map(tasks |> menu_tags) let #(indicator, tags) = case tags { GroupItem(tags) -> #("∩ ", tags) TaskItem(tags) -> #("· ", tags) } tags |> list.map(with: theme.tag) |> string.join(with: ", ") |> fn(x) { { margin <> theme.heading(indicator) <> x } |> wrap_line(with: margin <> " ") } } [heading, ..tag_items] } /// A type for preparing to print a formatted group of tasks, each with the same /// first tag. /// type MenuItem { MenuItem(doc: String, tag: String, tasks: List(Set(String))) } fn menu_tasks_loop( acc: List(MenuItem), from tasks: List(Task), ) -> List(MenuItem) { case tasks { [Task(tags:, group_doc:, doc:, ..), ..tasks] -> case tags |> set.to_list { [tag, ..tags] -> { let tags = tags |> set.from_list let doc = case group_doc { Some(doc) -> doc None -> doc } case acc { [item, ..acc] if doc == item.doc && tag == item.tag -> [ // Group tag `Set` with previous `MenuItem` MenuItem(..item, tasks: [tags, ..item.tasks]), ..acc ] _else -> [ // Add new `MenuItem` MenuItem(doc:, tag:, tasks: [tags]), ..acc ] } } // Omit tagless `Task` [] -> acc } |> menu_tasks_loop(from: tasks) // Done [] -> acc |> list.reverse |> list.map(with: fn(x) { MenuItem(..x, tasks: x.tasks |> list.reverse) }) } } /// A type for indicating whether tags belong to a group of tasks or an /// individual task. /// type TagItem { GroupItem(List(String)) TaskItem(List(String)) } /// Converts tag sets into lists corresponding to a menu item's tasks, possibly /// prepending a list of tags shared by all item tasks in the process. /// fn menu_tags(from tag_sets: List(Set(String))) -> List(TagItem) { let group = case tag_sets |> list.reduce(with: set.intersection) { Ok(group) -> group _else -> set.new() } let tasks = { use acc, task <- list.fold(over: tag_sets |> list.reverse, from: []) case task |> set.difference(group) |> set.to_list { [] -> acc task -> [task |> TaskItem, ..acc] } } case group |> set.to_list { [] -> tasks tags if tasks == [] -> [tags |> TaskItem] tags -> [tags |> GroupItem, ..tasks] } } /// Returns printable sections for tasks selected from the `Handbook` based on /// the arguments given to the program. /// fn handbook_plan( for tasks: List(Task), with args: Args, from handbook: Handbook, ) -> List(List(String)) { let theme = handbook.theme let steps = tasks |> list.length let count = case steps |> int.to_string { "1" -> "a task" x -> x <> " tasks" } let name = handbook.name |> theme.highlight [ info(apply: theme), [""], usage(with: args |> plan_tags(from: handbook), apply: theme), [""], flags(with: handbook_flags, apply: theme), [""], [ { "Planning " <> count <> " for " <> name } |> theme.heading |> wrap_line(with: ""), ], tasks |> plan_tasks(with: args, and: steps, apply: theme) |> list.flatten, ] } /// Returns a themed string of tags parsed from the arguments given to the /// program. /// fn plan_tags(with args: Args, from handbook: Handbook) -> String { let theme = handbook.theme let first_tags = { use acc, Task(tags:, ..) <- list.fold(over: handbook.tasks, from: set.new()) case tags |> set.to_list { [tag, ..] -> acc |> set.insert(tag) [] -> acc } } let theme_tags = fn(tags) { use tag <- list.map(tags |> set.to_list) case first_tags |> set.contains(tag) { True -> tag |> theme.first_tag False -> tag } |> theme.tag } args.all_tags |> theme_tags |> list.append(case args.any_tags |> theme_tags { [] -> [] any_tags -> ["--any" |> theme.flag, ..any_tags] }) |> string.join(with: " ") } /// Returns grouped lines of selected tasks, each with description and tags. /// fn plan_tasks( from tasks: List(Task), with args: Args, and steps: Int, apply theme: Theme, ) -> List(List(String)) { use <- bool.guard(when: tasks == [], return: [ [theme.tab <> "None. The plan is empty!"], ]) let indicator = fn(step) { case steps { 1 -> "· " _else -> int.to_string(step) <> ": " } } let step_width = steps |> indicator |> safe_width let margin = theme.tab <> string.repeat(" ", times: step_width) let given_tags = args.all_tags |> set.union(args.any_tags) let maybe_given_tag = fn(tag) { case given_tags |> set.contains(tag) { True -> tag |> theme.given_tag // TODO: revise False -> tag |> ansi(apply: [dim]) } } use Task(tags:, doc:, ..), i <- list.index_map(tasks) let heading = { i + 1 } |> indicator |> fn(x) { case not_(viewport_width_is_narrow(for: margin)) { True -> x |> pad_width_left(to: step_width) False -> x } } |> theme.heading |> ansi(apply: [italic]) let tags = case tags |> set.to_list { [tag, ..tags] -> tag |> maybe_given_tag |> theme.first_tag |> theme.tag |> list.fold(over: tags, with: fn(acc, tag) { tag |> maybe_given_tag |> theme.tag |> fn(x) { acc <> ", " <> x } }) [] -> "" } case not_(viewport_width_is_narrow(for: margin)) { True -> [ { theme.tab <> heading <> doc } |> wrap_line(with: margin), { margin <> theme.tab <> tags } |> wrap_line(with: margin <> theme.tab), ] False -> [ "\n" <> { heading <> doc } |> wrap_line(with: ""), { min_tab <> tags } |> wrap_line(with: min_tab), ] } } /// Returns lines with basic information about Kindly itself. /// fn info(apply theme: Theme) -> List(String) { let kindly = "kindly" |> theme.highlight |> ansi(apply: [italic]) let version = { "v" <> kindly_version() } |> ansi(apply: [italic]) let tagline = kindly_description() |> theme.flag |> ansi(apply: [italic]) [ { kindly <> " " <> version } |> wrap_line(with: ""), case not_(viewport_width_is_narrow(for: theme.tab)) { True -> { theme.tab <> tagline } |> wrap_line(with: theme.tab) False -> "\n" <> { min_tab <> tagline } |> wrap_line(with: min_tab) }, ] } /// Returns Kindly's current version, statically generated from `gleam.toml`. /// @external(javascript, "./kindly_ffi.ts", "kindly_version") fn kindly_version() -> String /// Returns Kindly's description, statically generated from `gleam.toml`. /// @external(javascript, "./kindly_ffi.ts", "kindly_description") fn kindly_description() -> String /// Returns lines summarising the format for invoking Kindly from the shell. /// fn usage(with args: String, apply theme: Theme) -> List(String) { let kindly = "kindly" |> theme.highlight let flags = "...flags?" |> theme.flag let params = "...arguments?" |> theme.parameter let args = case args { "" -> args _else -> " " <> args } [ "Usage" |> theme.heading, case not_(viewport_width_is_narrow(for: theme.tab)) { True -> { theme.tab <> kindly <> args <> " " <> flags <> " " <> params } |> wrap_line(with: theme.tab <> theme.tab) False -> "\n" <> { kindly <> args <> " " <> flags <> " " <> params } |> wrap_line(with: min_tab) }, ] } /// Returns a formatted description section with the given `lines`. /// fn description(lines: List(String), apply theme: Theme) -> List(String) { [ "Description" |> theme.heading, ..case not_(viewport_width_is_narrow(for: theme.tab)) { True -> { use line <- list.map(lines |> list.intersperse(with: "")) { theme.tab <> line } |> wrap_line(with: theme.tab) } False -> { use line <- list.map(lines) "\n" <> line |> wrap_line(with: "") } } ] } /// Returns lines summarising optional flag arguments that alter Kindly's /// default behaviour. /// fn flags( with flags: List(#(String, String)), apply theme: Theme, ) -> List(String) { [ "Flags" |> theme.heading, ..{ use #(name, description) <- list.map(flags) let blank_name = string.repeat(" ", times: safe_width(of: name)) let margin = theme.tab <> blank_name <> theme.tab case not_(viewport_width_is_narrow(for: margin)) { True -> { theme.tab <> theme.flag(name) <> theme.tab <> description } |> wrap_line(with: margin) False -> { "\n" <> theme.flag(name) <> "\n" } <> { min_tab <> description } |> wrap_line(with: min_tab) } } ] } /// Prints a summary of how long the help display or selected tasks took to run. /// fn print_duration(since timestamp: Int, apply theme: Theme) { // integer, fraction | noitcarf, regetni // For both parts, pad_left only applies when time doesn't have enough digits let #(noitcarf, regetni) = { now() - timestamp } |> int.to_string |> string.to_graphemes |> list.reverse |> list.split(at: 3) let integer = regetni |> list.sized_chunk(into: 3) |> list.intersperse(with: [","]) |> list.flatten |> list.reverse |> string.concat |> string.pad_start(to: 1, with: "0") let fraction = noitcarf |> list.reverse |> string.concat |> string.pad_start(to: 3, with: "0") let time = { "All done in " <> integer <> "." <> fraction <> " seconds" } |> theme.time |> wrap_line(with: "") { "\n" <> time } |> io.println } /// Promises to pass an `Ok` result's value to the given function, or exits the /// program on `Error`. /// fn exit_unless( is_ok cond: Promise(Result(a, Nil)), then f: fn(a) -> Promise(Never), ) -> Promise(Never) { use result <- promise.await(cond) case result { Ok(x) -> f(x) _else -> exit(code: 1) } } /// Prints an error message to `stderr`. /// fn print_error(message: String) -> Nil { let red = 31 let heading = "error" |> ansi(apply: [bold, red]) let message = { ": " <> message } |> ansi(apply: [bold]) { heading <> message } |> wrap_line(with: "") |> io.println_error } /// Returns a `Bool` indicating whether the current viewport has a narrow width. /// /// Useful for setting a responsive layout breakpoint. /// fn viewport_width_is_narrow(for margin: String) -> Bool { let width = get_max_width() |> int.min(terminal_width()) width - safe_width(of: margin) < min_width } /// Parses `KINDLY_MAX_WIDTH` from the environment, bounded by `min_width` and /// `max_width`. /// /// `KINDLY_MAX_WIDTH` can be used to customize Kindly's output for more /// comfortable viewing. /// fn get_max_width() -> Int { "KINDLY_MAX_WIDTH" |> get_env |> result.try(apply: int.parse) |> result.unwrap(or: max_width) |> int.max(min_width) } /// Returns a wrapped version of the given `line`, maintaining a given `margin` /// for any wrapped lines, attempting to keep words intact and fit content /// within the detected max viewport width. /// fn wrap_line(line: String, with margin: String) -> String { let width = get_max_width() |> int.min(terminal_width()) let replace_nbsps = string.replace(each: nbsp, with: " ", in: _) use <- bool.guard( when: safe_width(of: line) <= width, return: line |> replace_nbsps, ) case line |> string.split(on: " ") { [word, ..words] -> { #([word], safe_width(of: word)) |> wrap_line_loop(for: words, with: margin, at: width) |> replace_nbsps } [] -> "" } } fn wrap_line_loop( acc: #(List(String), Int), for words: List(String), with margin: String, at width: Int, ) -> String { let #(new_lines, acc_width) = acc case words { [word, ..words] -> { let word_width = word |> safe_width let new_width = acc_width + word_width + 1 case new_lines { // Append word to current line [line, ..new_lines] if new_width <= width -> #( [line <> " " <> word, ..new_lines], new_width, ) // Start a new line with margin and word _else -> #( [margin <> word, ..new_lines], common_width(margin) + word_width, ) } |> wrap_line_loop(for: words, with: margin, at: width) } // Done [] -> new_lines |> list.reverse |> string.join(with: "\n") } } /// Returns a `String` after clipping it to the given `width`, blanket resetting /// ANSI styles at the end, rather than detecting when a style reset was /// clipped. /// fn truncate_width(x: String, to width: Int) -> String { { x |> string.to_graphemes |> truncate_width_loop("", for: _, to: width, assuming: False) } <> "\u{1b}[m\u{1b}[K" } fn truncate_width_loop( acc: String, for xs: List(String), to width: Int, assuming escaped: Bool, ) -> String { case xs { [x, ..xs] if x == "\u{1b}" -> truncate_width_loop(acc <> x, for: xs, to: width, assuming: True) [x, ..xs] if escaped -> { let escaped = case x |> string.compare("@"), x |> string.compare("~") { Lt, _ | _, Gt -> True _or, _else -> False } truncate_width_loop(acc <> x, for: xs, to: width, assuming: escaped) } [x, ..xs] -> { let x = acc <> x case safe_width(of: x) <= width { True -> truncate_width_loop(x, for: xs, to: width, assuming: False) // Done False -> acc } } // Done _else -> acc } } /// Pads the given `String` to a specific `width` by adding space on the left. /// fn pad_width_left(x: String, to width: Int) -> String { case width - common_width(x) { pad if pad > 0 -> string.repeat(" ", times: pad) <> x _else -> x } } /// Pads the given `String` to a specific `width` by adding space on the right. /// fn pad_width_right(x: String, to width: Int) -> String { case width - common_width(x) { pad if pad > 0 -> x <> string.repeat(" ", times: pad) _else -> x } } /// Gets the common display width of the given `String`. /// fn common_width(of x: String) -> Int { x |> string_width.line } /// Gets the safe (max) display width of the given `String`. /// fn safe_width(of x: String) -> Int { let options = string_width.new() |> string_width.ambiguous_as_wide x |> string_width.line_with(options) } /// Negates the given `Bool` more visibly. /// fn not_(x: Bool) -> Bool { !x } /// Returns a `List` of arguments given when invoking the program. /// /// Arguments given directly to the program have precedence. /// /// If no arguments are given directly, they may be supplied via stdin. /// @external(javascript, "./kindly_ffi.ts", "args") fn args() -> List(String) /// Changes the current working directory. /// @external(javascript, "./kindly_ffi.ts", "change_directory") fn change_directory(to path: String) -> Result(Nil, String) /// Returns a script for adding Kindly command completion to the given shell. /// @external(javascript, "./kindly_ffi.ts", "completion_script") fn completion_script(for shell: String) -> Result(String, Nil) /// Returns the path of the current working directory. /// @external(javascript, "./kindly_ffi.ts", "current_directory") fn current_directory() -> String /// Exits the program. /// @external(javascript, "./kindly_ffi.ts", "exit") fn exit(code code: Int) -> Promise(Never) /// Promises to determine whether the given path exists and is readable. /// @external(javascript, "./kindly_ffi.ts", "file_is_readable") fn file_is_readable(path path: String) -> Promise(Bool) /// Promises to try writing a file to the given `path`, setting `content` and /// octal `mode`, creating directories as needed, and optionally overwriting a /// pre-existing file, /// @external(javascript, "./kindly_ffi.ts", "file_write") fn file_write( to path: String, with content: String, mode mode: Int, force overwrite: Bool, ) -> Promise(Result(Bool, Nil)) /// Promises to result in the `run` function from the project's `Handbook`, if /// found, or an `Error(Nil)` otherwise. /// @external(javascript, "./kindly_ffi.ts", "get_handbook") fn get_handbook() -> Promise(Result(fn() -> Promise(Never), String)) /// Returns the name of the current project read from `gleam.toml`, otherwise an /// empty `String`. /// @external(javascript, "./kindly_ffi.ts", "gleam_project") fn gleam_project() -> String /// Determines whether the given path is absolute. /// @external(javascript, "./kindly_ffi.ts", "path_is_absolute") fn path_is_absolute(path: String) -> Bool /// Joins a `List` of paths into a new path and normalizes the result. /// @external(javascript, "./kindly_ffi.ts", "path_join") fn path_join(parts: List(String)) -> String /// Returns the current project's root directory. /// @external(javascript, "./kindly_ffi.ts", "project_root") fn project_root() -> String /// Wraps a task-like function that may throw, returning /// `Promise(Result(Nil, Nil))`, discarding any success payload. /// /// Normalizes foreign JavaScript `Result` values across the module boundary. /// @external(javascript, "./kindly_ffi.ts", "rescue") fn rescue(f: fn() -> Promise(Result(a, Nil))) -> Promise(Result(Nil, Nil)) /// Determines whether the program is using the Deno runtime. /// @external(javascript, "./kindly_ffi.ts", "runtime_is_deno") fn runtime_is_deno() -> Bool /// Returns a `Bool` indicating whether the given `tag` is valid, e.g. it /// doesn't start with a "`-`" or contain any spaces. /// @external(javascript, "./kindly_ffi.ts", "tag_is_valid") fn tag_is_valid(tag: String) -> Bool /// Returns the terminal width, guessing, if necessary. /// @external(javascript, "./kindly_ffi.ts", "terminal_width") fn terminal_width() -> Int // ⬤ •╱ *╱. // ˙ ‧╱╱ // ╱╲*.· // ╲╱╲ // ꟸ ╲ ╲ Library // .˙‧∴.˙∵‧ // Ψ˙ ≁ ˙ // ↀ ʽ ⍦ // ⍭ ’ ‚ // ‟╷│ // ’ ╰┼╯ „ // ‟ /// The main Kindly data type, a named collection of tasks. /// pub opaque type Handbook { Handbook( name: String, tasks: List(Task), theme: Theme, run: fn() -> Promise(Never), ) } /// Returns a new Kindly `Handbook` with the given project name. /// @external(erlang, "kindly", "panic_from_erlang_api1") pub fn handbook(for name: String) -> Handbook { Handbook(name:, tasks: [], theme: default_theme(), run: main) |> update } /// Updates a handbook's `run` function with a new reference to the `Handbook`. /// /// The `run` function is only stored in the `Handbook` so its invocation will /// succeed with any combination of Gleam and JavaScript module and runtime; /// otherwise, JavaScript's `instanceof` keyword may fail due to differences /// between compiled Gleam and bundled JavaScript code. /// fn update(handbook: Handbook) -> Handbook { Handbook(..handbook, run: fn() { run_handbook(handbook) }) } /// A type for styles applied to Kindly output. /// pub type Theme { Theme( highlight: fn(String) -> String, heading: fn(String) -> String, tag: fn(String) -> String, first_tag: fn(String) -> String, given_tag: fn(String) -> String, flag: fn(String) -> String, parameter: fn(String) -> String, time: fn(String) -> String, tab: String, ) } /// Applies the given `Theme` to Kindly output. /// @external(erlang, "kindly", "panic_from_erlang_api2") pub fn theme(handbook: Handbook, with theme: Theme) -> Handbook { Handbook(..handbook, theme:) |> update } /// Returns Kindly's default display `Theme`. /// /// This theme uses a selection of the terminal's configured colours. /// @external(erlang, "kindly", "panic_from_erlang_api") pub fn default_theme() -> Theme { let blue = 34 let cyan = 36 let green = 32 let yellow = 33 let bright_black = 90 let bright_purple = 95 Theme( highlight: [bold, cyan] |> styler, heading: [bold, bright_purple] |> styler, tag: [yellow] |> styler, first_tag: [bold] |> styler, given_tag: [italic] |> styler, flag: [green] |> styler, parameter: [blue] |> styler, time: [bold, italic, bright_black] |> styler, tab: min_tab, ) } /// Returns a Gleam-inspired `Theme`. /// @external(erlang, "kindly", "panic_from_erlang_api") pub fn gleam_theme() -> Theme { let bit8 = fn(code) { [38, 5, code] } let blue = bit8(123) let green = bit8(158) let pink = bit8(219) let purple = bit8(183) let yellow = bit8(223) Theme( ..default_theme(), highlight: [bold, ..blue] |> styler, heading: [bold, ..pink] |> styler, tag: yellow |> styler, flag: purple |> styler, parameter: green |> styler, ) } /// Returns a `Theme` that uses the terminal's default display style. /// @external(erlang, "kindly", "panic_from_erlang_api") pub fn plain_theme() -> Theme { Theme( ..default_theme(), highlight: [] |> styler, heading: [] |> styler, tag: [] |> styler, first_tag: [] |> styler, given_tag: [underline] |> styler, flag: [] |> styler, parameter: [] |> styler, time: [] |> styler, ) } /// Returns a function that styles a `String` with the given ANSI codes. /// @external(erlang, "kindly", "panic_from_erlang_api1") pub fn styler(will_apply styles: List(Int)) -> fn(String) -> String { fn(content) { content |> ansi(apply: styles) } } /// The main Kindly unit type. /// pub type Task { Task( doc: String, tags: Set(String), action: fn(List(String), List(Task)) -> TaskResult, group_doc: Option(String), ) } /// A return type for `Task` actions. /// type TaskResult = Promise(Result(Nil, Nil)) /// Adds a `Task` to the `Handbook`. /// /// The given `action` is provided a `List` of argument strings and must return /// a `Promise(Result(Nil, Nil))` indicating success. /// @external(erlang, "kindly", "panic_from_erlang_api4") pub fn task( handbook: Handbook, doc doc: String, tags tags: List(String), action action: fn(List(String)) -> TaskResult, ) -> Handbook { let action = fn(args, _) { action(args) } handbook |> task_with_tasks(doc:, tags:, action:) |> update } /// Adds a `Task` to the `Handbook`, differing from a standard task in that its /// `action` is given the handbook's complete list of tasks in addition to any /// runtime arguments. /// /// As with `task`, the given `action` is also provided a `List` of argument /// strings and must return a `Promise(Result(Nil, Nil))` indicating success. /// @external(erlang, "kindly", "panic_from_erlang_api4") pub fn task_with_tasks( handbook: Handbook, doc doc: String, tags tags: List(String), action action: fn(List(String), List(Task)) -> TaskResult, ) -> Handbook { let task = Task( // gleam-format-wangle doc:, tags: tags |> set.from_list, action:, group_doc: None, ) Handbook(..handbook, tasks: handbook.tasks |> list.append([task])) |> update } /// Adds a `Task` group to the `Handbook`. /// /// Kindly's main help menu compresses the display of grouped tasks under the /// given `doc` and prepends the given `group_tags` for all grouped tasks. /// /// Conceptually similar to `map`, but with the aforementioned structural /// conveniences. /// @external(erlang, "kindly", "panic_from_erlang_api4") pub fn group( handbook: Handbook, doc doc: String, tags group_tags: List(String), apply f: fn(Handbook) -> Handbook, ) -> Handbook { let tasks = handbook.tasks let handbook = f(Handbook(..handbook, tasks: [])) let group_tags = group_tags |> set.from_list let group_tasks = { use task <- list.map(handbook.tasks) Task( // gleam-format-wangle ..task, tags: group_tags |> set.union(task.tags), group_doc: Some(doc), ) } Handbook(..handbook, tasks: tasks |> list.append(group_tasks)) |> update } /// Modifies the `Handbook` with the given function. /// /// Can be used in the main `Handbook` builder pipeline, for example, to keep /// tasks together with logic used to generate them. /// @external(erlang, "kindly", "panic_from_erlang_api2") pub fn map(handbook: Handbook, with f: fn(Handbook) -> Handbook) -> Handbook { f(handbook) |> update } /// Calls and awaits the given function's `Result`. On success, promises to /// return the callback function's `Result`. If either `Result` is an `Error`, /// an `Error(Nil)` is promised instead. /// /// Enables chaining function calls such that each `step` can only run after the /// previous `step` succeeded. /// @external(erlang, "kindly", "panic_from_erlang_api2") pub fn step( f: fn() -> Promise(Result(a, b)), then do: fn(a) -> Promise(Result(c, d)), ) -> Promise(Result(c, Nil)) { use result <- promise.await(f()) case result { Ok(x) -> do(x) |> promise.map(result.replace_error(_, Nil)) Error(_) -> Error(Nil) |> promise.resolve } } /// Combines `step` and `command`, enabling function chains akin to shell `&&` /// sequences. /// @external(erlang, "kindly", "panic_from_erlang_api3") pub fn command_step( run bin: String, with args: List(String), then do: fn() -> TaskResult, ) -> TaskResult { use _ <- step(fn() { command(run: bin, with: args) }) do() } /// Promises to return an `Ok(Nil)` result. Useful for ending a multistep `Task` /// successfully. /// @external(erlang, "kindly", "panic_from_erlang_api") pub fn resolve() -> TaskResult { Ok(Nil) |> promise.resolve } /// Promises to return an `Error(Nil)` result. Useful for ending a multistep /// `Task` in failure. /// @external(erlang, "kindly", "panic_from_erlang_api") pub fn reject() -> TaskResult { Error(Nil) |> promise.resolve } /// Returns the given `content` with ANSI `styles` applied, ending with a style /// reset. /// /// Returns the given `content` unstyled when the `NO_COLOR` or `NO_COLOUR` /// environment variable is truthy; likewise, if `kindly`'s output is piped, /// unless the `COLOR` or `COLOUR` environment variable is `always`. /// @external(erlang, "kindly", "panic_from_erlang_api2") @external(javascript, "./kindly_ffi.ts", "ansi") pub fn ansi(to content: String, apply styles: List(Int)) -> String /// Runs the given external binary with any given arguments. /// /// The command is executed as transparently as possible (capturing nothing). /// /// Returns a `Result(Nil, Nil)` indicating the command's success. /// @external(erlang, "kindly", "panic_from_erlang_api2") @external(javascript, "./kindly_ffi.ts", "command") pub fn command(run bin: String, with args: List(String)) -> TaskResult /// Returns a function that discards its input and just runs `command` with the /// given arguments. /// @external(erlang, "kindly", "panic_from_erlang_api2") pub fn just(run bin: String, with args: List(String)) -> fn(a) -> TaskResult { fn(_) { command(run: bin, with: args) } } /// A type for referring to Standard IO streams. /// pub type IoStream { Stdin Stdout Stderr } /// Returns a `Bool` indicating whether the given Standard IO stream is a /// terminal (TTY). /// @external(erlang, "kindly", "panic_from_erlang_api1") pub fn is_terminal(io_stream: IoStream) -> Bool { case io_stream { Stdin -> "stdin" Stdout -> "stdout" Stderr -> "stderr" } |> do_is_terminal } @external(javascript, "./kindly_ffi.ts", "is_terminal") fn do_is_terminal(stream: String) -> Bool /// Promises to set global state for `gleam_project` after trying to read a /// project name from `gleam.toml`. /// @external(javascript, "./kindly_ffi.ts", "set_gleam_project") fn set_gleam_project(dir: String) -> Promise(Nil) /// Results in the value of the given environment variable on success, or `Nil` /// if the variable is unset. /// @external(erlang, "kindly", "panic_from_erlang_api1") @external(javascript, "./kindly_ffi.ts", "get_env") pub fn get_env(name name: String) -> Result(String, Nil) /// Sets an environment variable to the given value. /// @external(erlang, "kindly", "panic_from_erlang_api2") @external(javascript, "./kindly_ffi.ts", "set_env") pub fn set_env(name name: String, value value: String) -> Nil /// Ensures the given environment variable is no longer set. /// @external(erlang, "kindly", "panic_from_erlang_api1") @external(javascript, "./kindly_ffi.ts", "unset_env") pub fn unset_env(name name: String) -> Nil /// Returns a monotonic timestamp for the current time in milliseconds, rounded /// down. /// @external(erlang, "kindly", "panic_from_erlang_api") @external(javascript, "./kindly_ffi.ts", "now") pub fn now() -> Int /// Converts a Gleam `Option(a)` to a TypeScript `a | undefined`. /// /// Exported here so it works after bundling. /// @external(erlang, "kindly", "panic_from_erlang_api1") @external(javascript, "./kindly_ffi.ts", "option_to_optional") @internal pub fn option_to_optional(option: Option(a)) -> a /// Fails loudly when attempting to run Kindly's API with Erlang. /// @internal pub fn panic_from_erlang_api() -> Never { panic as "Kindly’s API cannot be run with Erlang!" } @internal pub fn panic_from_erlang_api1(_) -> Never { panic_from_erlang_api() } @internal pub fn panic_from_erlang_api2(_, _) -> Never { panic_from_erlang_api() } @internal pub fn panic_from_erlang_api3(_, _, _) -> Never { panic_from_erlang_api() } @internal pub fn panic_from_erlang_api4(_, _, _, _) -> Never { panic_from_erlang_api() }