From 63bf66a1b013c13d3fd84f797e1adf4ec329bb70 Mon Sep 17 00:00:00 2001 From: Robert Attard Date: Wed, 24 Jan 2024 10:25:24 -0500 Subject: [PATCH 1/5] args count with struct --- examples/hello/manifest.toml | 8 +- examples/hello/src/hello.gleam | 2 + src/glint.gleam | 148 ++++++++++++++++++++++++++++++--- 3 files changed, 141 insertions(+), 17 deletions(-) diff --git a/examples/hello/manifest.toml b/examples/hello/manifest.toml index f5cdc76..e8c72f6 100644 --- a/examples/hello/manifest.toml +++ b/examples/hello/manifest.toml @@ -4,14 +4,14 @@ packages = [ { name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" }, { name = "filepath", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "534E8161A0DE192A9A105EFEC34369E9FD5834BB58ED449B5ACAEE8704358588" }, - { name = "gleam_community_ansi", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "AB7C3CCC894653637E02DC455D5890C8CF3064E83E78CFE61145A4C458D02DE6" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, - { name = "gleam_erlang", version = "0.23.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "C21CFB816C114784E669FFF4BBF433535EEA9960FA2F216209B8691E87156B96" }, + { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" }, { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, - { name = "gleescript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang", "snag", "tom", "filepath", "simplifile"], otp_app = "gleescript", source = "hex", outer_checksum = "F7C152E206167000420F90983E4D4A076703292AAC4335A9248BA46D380841AC" }, + { name = "gleescript", version = "1.0.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "simplifile", "snag", "tom", "gleam_erlang"], otp_app = "gleescript", source = "hex", outer_checksum = "F7C152E206167000420F90983E4D4A076703292AAC4335A9248BA46D380841AC" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, { name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], source = "local", path = "../.." }, - { name = "simplifile", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "2B7070FB8617474A35651F6AA27046576615C14A4D97B62FA7C40C24C55A6C5C" }, + { name = "simplifile", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "359CD7006E2F69255025C858CCC6407C11A876EC179E6ED1E46809E8DC6B1AAD" }, { name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" }, { name = "tom", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0831C73E45405A2153091226BF98FB485ED16376988602CC01A5FD086B82D577" }, ] diff --git a/examples/hello/src/hello.gleam b/examples/hello/src/hello.gleam index 683a400..7ef8b0c 100644 --- a/examples/hello/src/hello.gleam +++ b/examples/hello/src/hello.gleam @@ -110,6 +110,8 @@ pub fn app() { glint.new() // with an app name of "hello", this is used when printing help text |> glint.with_name("hello") + // show in usage text that the current app is run as a gleam module + |> glint.as_gleam_module // with pretty help enabled, using the built-in colours |> glint.with_pretty_help(glint.default_pretty_help()) // with a root command that executes the `hello` function diff --git a/src/glint.gleam b/src/glint.gleam index c0cf50b..205b4bb 100644 --- a/src/glint.gleam +++ b/src/glint.gleam @@ -3,6 +3,7 @@ import gleam/dict import gleam/option.{type Option, None, Some} import gleam/list import gleam/io +import gleam/int import gleam/string import snag.{type Result} import glint/flag.{type Flag, type Map as FlagMap} @@ -19,7 +20,11 @@ import gleam/function /// Config for glint /// pub type Config { - Config(pretty_help: Option(PrettyHelp), name: Option(String)) + Config( + pretty_help: Option(PrettyHelp), + name: Option(String), + as_gleam_module: Bool, + ) } /// PrettyHelp defines the header colours to be used when styling help text @@ -32,7 +37,11 @@ pub type PrettyHelp { /// Default config /// -pub const default_config = Config(pretty_help: None, name: None) +pub const default_config = Config( + pretty_help: None, + name: None, + as_gleam_module: False, +) // -- CONFIGURATION: FUNCTIONS -- @@ -57,11 +66,20 @@ pub fn without_pretty_help(glint: Glint(a)) -> Glint(a) { |> with_config(glint, _) } +/// Give the current glint application a name +/// pub fn with_name(glint: Glint(a), name: String) -> Glint(a) { Config(..glint.config, name: Some(name)) |> with_config(glint, _) } +/// Adjust the generated help text to reflect that the current glint app should be run as a gleam module. +/// Use in conjunction with `glint.with_name` to get usage text output like `gleam run -m ` +pub fn as_gleam_module(glint: Glint(a)) -> Glint(a) { + Config(..glint.config, as_gleam_module: True) + |> with_config(glint, _) +} + // --- CORE --- // -- CORE: TYPES -- @@ -72,10 +90,23 @@ pub opaque type Glint(a) { Glint(config: Config, cmd: CommandNode(a), global_flags: FlagMap) } +/// Specify the expected number of arguments with this type and the `glint.args_count` function +/// +pub type ArgsCount { + Equal(Int) + AtLeast(Int) + AtMost(Int) +} + /// CommandNode contents /// pub opaque type Command(a) { - Command(do: Runner(a), flags: FlagMap, description: String) + Command( + do: Runner(a), + flags: FlagMap, + description: String, + args_count: Option(ArgsCount), + ) } /// Input type for `Runner`. @@ -181,7 +212,7 @@ fn sanitize_path(path: List(String)) -> List(String) { /// Create a Command(a) from a Runner(a) /// pub fn command(do runner: Runner(a)) -> Command(a) { - Command(do: runner, flags: dict.new(), description: "") + Command(do: runner, flags: dict.new(), description: "", args_count: None) } /// Attach a description to a Command(a) @@ -190,6 +221,12 @@ pub fn description(cmd: Command(a), description: String) -> Command(a) { Command(..cmd, description: description) } +/// Specify a specific number of args that a given command expects +/// +pub fn args(cmd: Command(a), count: ArgsCount) -> Command(a) { + Command(..cmd, args_count: Some(count)) +} + /// add a `flag.Flag` to a `Command` /// pub fn flag( @@ -334,6 +371,22 @@ fn do_execute( } } +fn args_compare(expected: ArgsCount, actual: Int) -> Result(Nil) { + case expected { + Equal(expected) if actual == expected -> Ok(Nil) + AtLeast(expected) if actual >= expected -> Ok(Nil) + AtMost(expected) if actual <= expected -> Ok(Nil) + Equal(expected) -> Error(int.to_string(expected)) + AtLeast(expected) -> Error("at least " <> int.to_string(expected)) + AtMost(expected) -> Error("at most " <> int.to_string(expected)) + } + |> result.map_error(fn(err) { + snag.new( + "expected: " <> err <> " argument(s), provided: " <> int.to_string(actual), + ) + }) +} + /// Executes the current root command. /// fn execute_root( @@ -349,10 +402,25 @@ fn execute_root( from: dict.merge(global_flags, contents.flags), with: flag.update_flags, )) - CommandInput(args, new_flags) - |> contents.do - |> Out - |> Ok + + case contents.args_count { + Some(expected_count) -> { + let args_count = list.length(args) + use _ <- result.map( + args_compare(expected_count, args_count) + |> snag.context("invalid number of arguments provided"), + ) + CommandInput(args, new_flags) + |> contents.do + |> Out + } + _ -> { + CommandInput(args, new_flags) + |> contents.do + |> Out + |> Ok + } + } } None -> snag.error("command not found") } @@ -491,6 +559,8 @@ type CommandHelp { flags: List(FlagHelp), // A command can have >= 0 subcommands associated with it subcommands: List(Metadata), + // A command cann have a set number of arguments + args_count: Option(ArgsCount), ) } @@ -503,11 +573,12 @@ fn build_command_help_metadata( node: CommandNode(_), global_flags: FlagMap, ) -> CommandHelp { - let #(description, flags) = case node.contents { - None -> #("", []) + let #(description, flags, args_count) = case node.contents { + None -> #("", [], None) Some(cmd) -> #( cmd.description, build_flags_help(dict.merge(global_flags, cmd.flags)), + cmd.args_count, ) } @@ -515,6 +586,7 @@ fn build_command_help_metadata( meta: Metadata(name: name, description: description), flags: flags, subcommands: build_subcommands_help(node.subcommands), + args_count: args_count, ) } @@ -595,6 +667,7 @@ fn flags_help_to_usage_strings(help: List(FlagHelp)) -> List(String) { } /// generate the usage help text for the flags of a command +/// fn flags_help_to_usage_string(help: List(FlagHelp)) -> String { use <- bool.guard(help == [], "") @@ -607,21 +680,70 @@ fn flags_help_to_usage_string(help: List(FlagHelp)) -> String { |> sb.to_string } +/// convert an ArgsCount to a string for usage text +/// +fn args_count_to_usage_string(count: ArgsCount) -> String { + case count { + Equal(0) -> "" + Equal(1) -> "[ 1 argument ]" + Equal(n) -> "[ " <> int.to_string(n) <> " arguments ]" + AtLeast(n) -> "[ " <> int.to_string(n) <> " or more arguments ]" + AtMost(n) -> "[ " <> int.to_string(n) <> " or less arguments ]" + } +} + +fn args_count_to_notes_string(count: ArgsCount) -> String { + "this command accepts " + <> case count { + Equal(0) -> "no arguments" + Equal(1) -> "1 argument" + Equal(n) -> int.to_string(n) <> " arguments" + AtLeast(n) -> int.to_string(n) <> " or more arguments" + AtMost(n) -> int.to_string(n) <> " or less arguments" + } +} + /// convert a CommandHelp to a styled usage block /// fn command_help_to_usage_string(help: CommandHelp, config: Config) -> String { - let app_name = option.unwrap(config.name, "gleam run") + let app_name = case config.name { + Some(name) if config.as_gleam_module -> "gleam run -m " <> name + Some(name) -> name + None -> "gleam run" + } + let flags = flags_help_to_usage_string(help.flags) + let args = case help.args_count { + None -> "" + Some(count) -> args_count_to_usage_string(count) + } + + let notes = + [ + help.args_count + |> option.map(args_count_to_notes_string) + |> option.unwrap(""), + ] + |> list.filter_map(fn(elem) { + case elem { + "" -> Error(Nil) + s -> Ok(string.append("\n- ", s)) + } + }) + |> string.concat + case config.pretty_help { None -> usage_heading Some(pretty) -> heading_style(usage_heading, pretty.usage) } <> "\n\t" <> app_name - <> wrap_with_space(help.meta.name) - <> "[ ARGS ] " + <> help.meta.name + <> wrap_with_space(args) <> flags + <> "\n" + <> notes } // -- HELP - FUNCTIONS - STRINGIFIERS - FLAGS -- From a04428ec48acd7f05e45cf775a67a3c2e340052a Mon Sep 17 00:00:00 2001 From: Robert Attard Date: Sun, 28 Jan 2024 17:10:02 -0500 Subject: [PATCH 2/5] basic implementation of named args --- examples/hello/README.md | 4 +- examples/hello/src/hello.gleam | 34 ++-- examples/hello/test/hello_test.gleam | 7 +- manifest.toml | 2 +- src/glint.gleam | 291 ++++++++++++++++----------- test/glint_test.gleam | 20 +- 6 files changed, 216 insertions(+), 142 deletions(-) diff --git a/examples/hello/README.md b/examples/hello/README.md index 2d57891..3f80ab5 100644 --- a/examples/hello/README.md +++ b/examples/hello/README.md @@ -10,10 +10,10 @@ Feel free to browse `src/hello.gleam` to get a sense of how a small cli applicat You can run this example from the `examples/hello` directory by calling `gleam run` which prints `Hello, !` -The `hello` application accepts any number of arguments, being the names of people to say hello to. +The `hello` application accepts at least one argument, being the names of people to say hello to. - No input: `gleam run` -> prints "Hello, Joe!" -- One input: `gleam run Rob` -> prints "Hello, Rob!" +- One input: `gleam run Joe` -> prints "Hello, Joe!" - Two inputs: `gleam run Rob Louis` -> prints "Hello, Rob and Louis!" - \>2 inputs: `gleam run Rob Louis Hayleigh` -> prints "Hello, Rob, Louis and Hayleigh!" diff --git a/examples/hello/src/hello.gleam b/examples/hello/src/hello.gleam index 7ef8b0c..8885b73 100644 --- a/examples/hello/src/hello.gleam +++ b/examples/hello/src/hello.gleam @@ -2,6 +2,7 @@ import gleam/io import gleam/list import gleam/string.{uppercase} +import gleam/dict // external dep imports import snag // glint imports @@ -14,9 +15,8 @@ import argv /// a helper function to join a list of names fn join_names(names: List(String)) -> String { case names { - [] -> "Joe" - [name] -> name - [name, ..rest] -> do_join_names(rest, name) + [] -> "" + _ -> do_join_names(names, "") } } @@ -29,10 +29,6 @@ fn do_join_names(names: List(String), acc: String) { } } -pub fn message(names: List(String)) { - "Hello, " <> join_names(names) <> "!" -} - pub fn capitalize(msg, caps) -> String { case caps { True -> uppercase(msg) @@ -41,9 +37,13 @@ pub fn capitalize(msg, caps) -> String { } /// hello is a function that -pub fn hello(names: List(String), caps: Bool, repeat: Int) -> String { - names - |> message +pub fn hello( + primary: String, + rest: List(String), + caps: Bool, + repeat: Int, +) -> String { + { "Hello, " <> primary <> join_names(rest) <> "!" } |> capitalize(caps) |> list.repeat(repeat) |> string.join("\n") @@ -89,19 +89,29 @@ fn gtz(n: Int) -> snag.Result(Nil) { pub fn hello_cmd() -> glint.Command(String) { { use input <- glint.command() + // the caps flag has a default value, so we can be sure it will always be present let assert Ok(caps) = flag.get_bool(from: input.flags, for: caps) + // the repeat flag has a default value, so we can be sure it will always be present let assert Ok(repeat) = flag.get_int(from: input.flags, for: repeat) + + // access named args directly + let assert Ok(name) = dict.get(input.named_args, "name") + // call the hello function with all necessary inputs - hello(input.args, caps, repeat) + hello(name, input.args, caps, repeat) } // with flag `caps` |> glint.flag(caps, caps_flag()) // with flag `repeat` |> glint.flag(repeat, repeat_flag()) // with flag `repeat` - |> glint.description("Prints Hello, !") + |> glint.description("Prints Hello, !") + // with a first arg called name + |> glint.named_args(["name"]) + // requiring at least 1 argument + |> glint.count_args(glint.MinArgs(1)) } // the function that describes our cli structure diff --git a/examples/hello/test/hello_test.gleam b/examples/hello/test/hello_test.gleam index 0bbc0e8..fc54714 100644 --- a/examples/hello/test/hello_test.gleam +++ b/examples/hello/test/hello_test.gleam @@ -13,10 +13,7 @@ type TestCase { pub fn hello_test() { use tc <- list.each([ - TestCase([], False, 1, "Hello, Joe!"), TestCase(["Rob"], False, 1, "Hello, Rob!"), - TestCase([], True, 1, "HELLO, JOE!"), - TestCase([], True, 2, "HELLO, JOE!\nHELLO, JOE!"), TestCase(["Rob"], True, 1, "HELLO, ROB!"), TestCase(["Tony", "Maria"], True, 1, "HELLO, TONY AND MARIA!"), TestCase( @@ -33,6 +30,8 @@ pub fn hello_test() { "Hello, Tony, Maria and Nadia!", ), ]) - hello.hello(tc.input, tc.caps, tc.repeat) + + let assert [head, ..rest] = tc.input + hello.hello(head, rest, tc.caps, tc.repeat) |> should.equal(tc.expected) } diff --git a/manifest.toml b/manifest.toml index 38aa2c7..bbbeb8f 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,7 +2,7 @@ # You typically do not need to edit this file packages = [ - { name = "gleam_community_ansi", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "AB7C3CCC894653637E02DC455D5890C8CF3064E83E78CFE61145A4C458D02DE6" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, diff --git a/src/glint.gleam b/src/glint.gleam index 205b4bb..a5c5f3e 100644 --- a/src/glint.gleam +++ b/src/glint.gleam @@ -90,29 +90,44 @@ pub opaque type Glint(a) { Glint(config: Config, cmd: CommandNode(a), global_flags: FlagMap) } -/// Specify the expected number of arguments with this type and the `glint.args_count` function +/// Specify the expected number of arguments with this type and the `glint.count_args` function /// pub type ArgsCount { - Equal(Int) - AtLeast(Int) - AtMost(Int) + /// Specifies that a command must accept a specific number of arguments + /// + EqArgs(Int) + /// Specifies that a command must accept a minimum number of arguments + /// + MinArgs(Int) } -/// CommandNode contents +/// A glint command /// pub opaque type Command(a) { Command( do: Runner(a), flags: FlagMap, description: String, - args_count: Option(ArgsCount), + count_args: Option(ArgsCount), + named_args: List(String), ) } -/// Input type for `Runner`. +/// The input type for `Runner`. /// +/// Arguments passed to `glint` are provided as the `args` field. +/// +/// Flags passed to `glint` are provided as the `flags` field. +/// +/// If named arguments are specified at command creation, they will be accessible via the `named_args` field. +/// IMPORTANT: Arguments matched by `named_args` will not be present in the `args` field. +/// pub type CommandInput { - CommandInput(args: List(String), flags: FlagMap) + CommandInput( + args: List(String), + flags: FlagMap, + named_args: dict.Dict(String, String), + ) } /// Function type to be run by `glint`. @@ -129,7 +144,7 @@ type CommandNode(a) { ) } -/// Ok type for command execution +/// Ok type for command execution /// pub type Out(a) { /// Container for the command return value @@ -212,7 +227,13 @@ fn sanitize_path(path: List(String)) -> List(String) { /// Create a Command(a) from a Runner(a) /// pub fn command(do runner: Runner(a)) -> Command(a) { - Command(do: runner, flags: dict.new(), description: "", args_count: None) + Command( + do: runner, + flags: dict.new(), + description: "", + count_args: None, + named_args: [], + ) } /// Attach a description to a Command(a) @@ -222,12 +243,22 @@ pub fn description(cmd: Command(a), description: String) -> Command(a) { } /// Specify a specific number of args that a given command expects -/// -pub fn args(cmd: Command(a), count: ArgsCount) -> Command(a) { - Command(..cmd, args_count: Some(count)) +/// +pub fn count_args(cmd: Command(a), count: ArgsCount) -> Command(a) { + Command(..cmd, count_args: Some(count)) +} + +/// Add a list of named arguments to a Command +/// These named arguments will be matched with the first N arguments passed to the command +/// All named arguments must match for a command to succeed, this is considered an implicit MinArgs(N) +/// This works in combination with CommandInput.named_args which will contain the matched args in a Dict(String,String) +/// IMPORTANT: Matched named arguments will not be present in CommandInput.args +/// +pub fn named_args(cmd: Command(a), args: List(String)) -> Command(a) { + Command(..cmd, named_args: args) } -/// add a `flag.Flag` to a `Command` +/// Add a `flag.Flag` to a `Command` /// pub fn flag( cmd: Command(a), @@ -238,8 +269,8 @@ pub fn flag( } /// Add a `flag.Flag to a `Command` when the flag name and builder are bundled as a #(String, flag.FlagBuilder(a)). -/// -/// This is merely a convenience function and calls `glint.flag` under the hood. +/// +/// This is merely a convenience function and calls `glint.flag` under the hood. /// pub fn flag_tuple( cmd: Command(a), @@ -249,7 +280,7 @@ pub fn flag_tuple( } /// Add multiple `Flag`s to a `Command`, note that this function uses `Flag` and not `FlagBuilder(_)`, so the user will need to call `flag.build` before providing the flags here. -/// +/// /// It is recommended to call `glint.flag` instead. /// pub fn flags(cmd: Command(a), with flags: List(#(String, Flag))) -> Command(a) { @@ -279,10 +310,10 @@ pub fn global_flag_tuple( global_flag(glint, tup.0, tup.1) } -/// Add global flags to the existing command tree. -/// +/// Add global flags to the existing command tree. +/// /// Like `glint.flags`, this function requires `Flag`s insead of `FlagBuilder(_)`. -/// +/// /// It is recommended to use `glint.global_flag` instead. /// pub fn global_flags(glint: Glint(a), flags: List(#(String, Flag))) -> Glint(a) { @@ -373,12 +404,10 @@ fn do_execute( fn args_compare(expected: ArgsCount, actual: Int) -> Result(Nil) { case expected { - Equal(expected) if actual == expected -> Ok(Nil) - AtLeast(expected) if actual >= expected -> Ok(Nil) - AtMost(expected) if actual <= expected -> Ok(Nil) - Equal(expected) -> Error(int.to_string(expected)) - AtLeast(expected) -> Error("at least " <> int.to_string(expected)) - AtMost(expected) -> Error("at most " <> int.to_string(expected)) + EqArgs(expected) if actual == expected -> Ok(Nil) + MinArgs(expected) if actual >= expected -> Ok(Nil) + EqArgs(expected) -> Error(int.to_string(expected)) + MinArgs(expected) -> Error("at least " <> int.to_string(expected)) } |> result.map_error(fn(err) { snag.new( @@ -395,35 +424,34 @@ fn execute_root( args: List(String), flag_inputs: List(String), ) -> CmdResult(a) { - case cmd.contents { - Some(contents) -> { - use new_flags <- result.try(list.try_fold( - over: flag_inputs, - from: dict.merge(global_flags, contents.flags), - with: flag.update_flags, - )) - - case contents.args_count { - Some(expected_count) -> { - let args_count = list.length(args) - use _ <- result.map( - args_compare(expected_count, args_count) - |> snag.context("invalid number of arguments provided"), - ) - CommandInput(args, new_flags) - |> contents.do - |> Out - } - _ -> { - CommandInput(args, new_flags) - |> contents.do - |> Out - |> Ok - } - } - } - None -> snag.error("command not found") + { + use contents <- option.map(cmd.contents) + use new_flags <- result.try(list.try_fold( + over: flag_inputs, + from: dict.merge(global_flags, contents.flags), + with: flag.update_flags, + )) + + use _ <- result.try(case contents.count_args { + Some(count) -> + args_compare(count, list.length(args)) + |> snag.context("invalid number of arguments provided") + None -> Ok(Nil) + }) + + let #(named_args, rest) = list.split(args, list.length(contents.named_args)) + + use named_args_dict <- result.map( + contents.named_args + |> list.strict_zip(named_args) + |> result.replace_error(snag.new("not enough arguments")), + ) + + CommandInput(rest, new_flags, dict.from_list(named_args_dict)) + |> contents.do + |> Out } + |> option.unwrap(snag.error("command not found")) |> snag.context("failed to run command") } @@ -499,13 +527,6 @@ pub fn help_flag() -> String { // -- HELP: FUNCTIONS -- -fn wrap_with_space(s: String) -> String { - case s { - "" -> " " - _ -> " " <> s <> " " - } -} - /// generate the help text for a command fn cmd_help( path: List(String), @@ -559,8 +580,10 @@ type CommandHelp { flags: List(FlagHelp), // A command can have >= 0 subcommands associated with it subcommands: List(Metadata), - // A command cann have a set number of arguments - args_count: Option(ArgsCount), + // A command cann have a set number of arguments + count_args: Option(ArgsCount), + // A command can specify named arguments + named_args: List(String), ) } @@ -573,12 +596,13 @@ fn build_command_help_metadata( node: CommandNode(_), global_flags: FlagMap, ) -> CommandHelp { - let #(description, flags, args_count) = case node.contents { - None -> #("", [], None) + let #(description, flags, count_args, named_args) = case node.contents { + None -> #("", [], None, []) Some(cmd) -> #( cmd.description, build_flags_help(dict.merge(global_flags, cmd.flags)), - cmd.args_count, + cmd.count_args, + cmd.named_args, ) } @@ -586,12 +610,13 @@ fn build_command_help_metadata( meta: Metadata(name: name, description: description), flags: flags, subcommands: build_subcommands_help(node.subcommands), - args_count: args_count, + count_args: count_args, + named_args: named_args, ) } /// generate the string representation for the type of a flag -/// +/// fn flag_type_info(flag: Flag) { case flag.value { flag.I(_) -> "INT" @@ -605,7 +630,7 @@ fn flag_type_info(flag: Flag) { } /// build the help representation for a list of flags -/// +/// fn build_flags_help(flag: FlagMap) -> List(FlagHelp) { use acc, name, flag <- dict.fold(flag, []) [ @@ -618,7 +643,7 @@ fn build_flags_help(flag: FlagMap) -> List(FlagHelp) { } /// build the help representation for a list of subcommands -/// +/// fn build_subcommands_help( subcommands: dict.Dict(String, CommandNode(_)), ) -> List(Metadata) { @@ -637,7 +662,7 @@ fn build_subcommands_help( // -- HELP - FUNCTIONS - STRINGIFIERS -- /// convert a CommandHelp to a styled string -/// +/// fn command_help_to_string(help: CommandHelp, config: Config) -> String { // create the header block from the name and description let header_items = @@ -649,7 +674,7 @@ fn command_help_to_string(help: CommandHelp, config: Config) -> String { [ header_items, command_help_to_usage_string(help, config), - flags_help(help.flags, config), + flags_help_to_string(help.flags, config), subcommands_help_to_string(help.subcommands, config), ] |> list.filter(is_not_empty) @@ -659,7 +684,7 @@ fn command_help_to_string(help: CommandHelp, config: Config) -> String { // -- HELP - FUNCTIONS - STRINGIFIERS - USAGE -- /// convert a List(FlagHelp) to a list of strings for use in usage text -/// +/// fn flags_help_to_usage_strings(help: List(FlagHelp)) -> List(String) { help |> list.map(flag_help_to_string) @@ -667,7 +692,7 @@ fn flags_help_to_usage_strings(help: List(FlagHelp)) -> List(String) { } /// generate the usage help text for the flags of a command -/// +/// fn flags_help_to_usage_string(help: List(FlagHelp)) -> String { use <- bool.guard(help == [], "") @@ -681,30 +706,73 @@ fn flags_help_to_usage_string(help: List(FlagHelp)) -> String { } /// convert an ArgsCount to a string for usage text -/// +/// fn args_count_to_usage_string(count: ArgsCount) -> String { case count { - Equal(0) -> "" - Equal(1) -> "[ 1 argument ]" - Equal(n) -> "[ " <> int.to_string(n) <> " arguments ]" - AtLeast(n) -> "[ " <> int.to_string(n) <> " or more arguments ]" - AtMost(n) -> "[ " <> int.to_string(n) <> " or less arguments ]" + EqArgs(0) -> "" + EqArgs(1) -> "[ 1 argument ]" + EqArgs(n) -> "[ " <> int.to_string(n) <> " arguments ]" + MinArgs(n) -> "[ " <> int.to_string(n) <> " or more arguments ]" } } -fn args_count_to_notes_string(count: ArgsCount) -> String { - "this command accepts " - <> case count { - Equal(0) -> "no arguments" - Equal(1) -> "1 argument" - Equal(n) -> int.to_string(n) <> " arguments" - AtLeast(n) -> int.to_string(n) <> " or more arguments" - AtMost(n) -> int.to_string(n) <> " or less arguments" +fn args_count_to_notes_string(count: Option(ArgsCount)) -> String { + { + use count <- option.map(count) + "this command accepts " + <> case count { + EqArgs(0) -> "no arguments" + EqArgs(1) -> "1 argument" + EqArgs(n) -> int.to_string(n) <> " arguments" + MinArgs(n) -> int.to_string(n) <> " or more arguments" + } + } + |> option.unwrap("") +} + +fn named_args_to_notes_string(named: List(String)) -> String { + named + |> list.map(fn(name) { "\"" <> name <> "\"" }) + |> string.join(", ") + |> string_map(fn(s) { "this command has named arguments: " <> s }) +} + +fn args_to_usage_string(count: Option(ArgsCount), named: List(String)) -> String { + case + named + |> list.map(fn(s) { "<" <> s <> ">" }) + |> string.join(" ") + { + "" -> + count + |> option.map(args_count_to_usage_string) + |> option.unwrap("[ ARGS ]") + named_args -> + count + |> option.map(fn(count) { + case count { + EqArgs(_) -> named_args + MinArgs(_) -> named_args <> "..." + } + }) + |> option.unwrap(named_args) } } +fn usage_notes(count: Option(ArgsCount), named: List(String)) -> String { + [args_count_to_notes_string(count), named_args_to_notes_string(named)] + |> list.filter_map(fn(elem) { + case elem { + "" -> Error(Nil) + s -> Ok(string.append("\n* ", s)) + } + }) + |> string.concat + |> string_map(fn(s) { "\nnotes:" <> s }) +} + /// convert a CommandHelp to a styled usage block -/// +/// fn command_help_to_usage_string(help: CommandHelp, config: Config) -> String { let app_name = case config.name { Some(name) if config.as_gleam_module -> "gleam run -m " <> name @@ -714,24 +782,7 @@ fn command_help_to_usage_string(help: CommandHelp, config: Config) -> String { let flags = flags_help_to_usage_string(help.flags) - let args = case help.args_count { - None -> "" - Some(count) -> args_count_to_usage_string(count) - } - - let notes = - [ - help.args_count - |> option.map(args_count_to_notes_string) - |> option.unwrap(""), - ] - |> list.filter_map(fn(elem) { - case elem { - "" -> Error(Nil) - s -> Ok(string.append("\n- ", s)) - } - }) - |> string.concat + let args = args_to_usage_string(help.count_args, help.named_args) case config.pretty_help { None -> usage_heading @@ -739,18 +790,17 @@ fn command_help_to_usage_string(help: CommandHelp, config: Config) -> String { } <> "\n\t" <> app_name - <> help.meta.name - <> wrap_with_space(args) + <> string_map(help.meta.name, string.append(" ", _)) + <> string_map(args, fn(s) { " " <> s <> " " }) <> flags - <> "\n" - <> notes + <> usage_notes(help.count_args, help.named_args) } // -- HELP - FUNCTIONS - STRINGIFIERS - FLAGS -- /// generate the usage help string for a command -/// -fn flags_help(help: List(FlagHelp), config: Config) -> String { +/// +fn flags_help_to_string(help: List(FlagHelp), config: Config) -> String { use <- bool.guard(help == [], "") case config.pretty_help { @@ -766,13 +816,13 @@ fn flags_help(help: List(FlagHelp), config: Config) -> String { } /// generate the help text for a flag without a description -/// +/// fn flag_help_to_string(help: FlagHelp) -> String { flag.prefix <> help.meta.name <> "=<" <> help.type_ <> ">" } /// generate the help text for a flag with a description -/// +/// fn flag_help_to_string_with_description(help: FlagHelp) -> String { flag_help_to_string(help) <> "\t\t" <> help.meta.description } @@ -780,7 +830,7 @@ fn flag_help_to_string_with_description(help: FlagHelp) -> String { // -- HELP - FUNCTIONS - STRINGIFIERS - SUBCOMMANDS -- /// generate the styled help text for a list of subcommands -/// +/// fn subcommands_help_to_string(help: List(Metadata), config: Config) -> String { use <- bool.guard(help == [], "") @@ -798,10 +848,17 @@ fn subcommands_help_to_string(help: List(Metadata), config: Config) -> String { } /// generate the help text for a single subcommand given its name and description -/// +/// fn subcommand_help_to_string(help: Metadata) -> String { case help.description { "" -> help.name _ -> help.name <> "\t\t" <> help.description } } + +fn string_map(s: String, f: fn(String) -> String) -> String { + case s { + "" -> "" + _ -> f(s) + } +} diff --git a/test/glint_test.gleam b/test/glint_test.gleam index a9d8786..08422cb 100644 --- a/test/glint_test.gleam +++ b/test/glint_test.gleam @@ -134,10 +134,12 @@ pub fn help_test() { let cli = glint.new() |> glint.with_name("test") + |> glint.as_gleam_module |> glint.global_flag(global_flag.0, global_flag.1) |> glint.add( at: [], do: glint.command(do: nil) + |> glint.named_args(["arg1", "arg2"]) |> glint.flag(flag_1.0, flag_1.1) |> glint.description("This is the root command"), ) @@ -163,6 +165,8 @@ pub fn help_test() { |> glint.add( at: ["cmd2"], do: glint.command(nil) + |> glint.named_args(["arg1", "arg2"]) + |> glint.count_args(glint.MinArgs(2)) |> glint.description("This is cmd2"), ) |> glint.add( @@ -172,7 +176,7 @@ pub fn help_test() { ) // execute root command - glint.execute(cli, []) + glint.execute(cli, ["a", "b"]) |> should.equal(Ok(Out(Nil))) // help message for root command @@ -182,7 +186,9 @@ pub fn help_test() { "This is the root command USAGE: -\ttest [ ARGS ] [ --flag1= --global= ] +\tgleam run -m test [ --flag1= --global= ] +notes: +* this command has named arguments: \"arg1\", \"arg2\" FLAGS: \t--flag1=\t\tThis is flag1 @@ -204,7 +210,7 @@ SUBCOMMANDS: This is cmd1 USAGE: -\ttest cmd1 [ ARGS ] [ --flag2= --flag5= --global= ] +\tgleam run -m test cmd1 [ ARGS ] [ --flag2= --flag5= --global= ] FLAGS: \t--flag2=\t\tThis is flag2 @@ -226,7 +232,7 @@ SUBCOMMANDS: This is cmd4 USAGE: -\ttest cmd1 cmd4 [ ARGS ] [ --flag4= --global= ] +\tgleam run -m test cmd1 cmd4 [ ARGS ] [ --flag4= --global= ] FLAGS: \t--flag4=\t\tThis is flag4 @@ -234,7 +240,6 @@ FLAGS: \t--help\t\t\tPrint help information", )), ) - // help message for command with no additional flags glint.execute(cli, ["cmd2", glint.help_flag()]) |> should.equal( @@ -243,7 +248,10 @@ FLAGS: This is cmd2 USAGE: -\ttest cmd2 [ ARGS ] [ --global= ] +\tgleam run -m test cmd2 ... [ --global= ] +notes: +* this command accepts 2 or more arguments +* this command has named arguments: \"arg1\", \"arg2\" FLAGS: \t--global=\t\tThis is a global flag From 53127ecf97eef2d086f0f81a34d16902023d4b1d Mon Sep 17 00:00:00 2001 From: Robert Attard Date: Sun, 28 Jan 2024 17:18:15 -0500 Subject: [PATCH 3/5] actions gleam version --- .github/workflows/main.yml | 2 +- .github/workflows/release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 18f32ad..24d55cc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: erlang: ["25.3.2.3", "26.0.2"] - gleam: ["0.33.0"] + gleam: ["0.34.1"] steps: - uses: actions/checkout@v2 - uses: ./.github/actions/test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 076823d..770b174 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v2 - uses: ./.github/actions/test with: - gleam-version: "0.33.0" + gleam-version: "0.34.1" - name: publish to hex env: HEXPM_USER: ${{ secrets.HEXPM_USER }} From 6027614ce063d77b91daccdb284c8af03aebc2ad Mon Sep 17 00:00:00 2001 From: Robert Attard Date: Sun, 28 Jan 2024 17:20:43 -0500 Subject: [PATCH 4/5] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ba917..84ac01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - refactor of help generation logic, no change to help text output - the `glint/flag` module loses the `flags_help` and `flag_type_help` functions +- the `glint` module gains the ArgsCount type and the `count_args` function to support exact and minimum arguments count +- the `glint` module gains the `named_args` function to support named arguments +- the `glint.CommandInput` type gains the `.named_args` field to access named arguments +- help text has been updated to support named and counted arguments ## [0.14.0](https://github.com/TanklesXL/glint/compare/v0.13.0...v0.14.0) From fba229a9a90e077b60dad60b890015b650c1a24c Mon Sep 17 00:00:00 2001 From: Robert Attard Date: Sun, 28 Jan 2024 17:22:14 -0500 Subject: [PATCH 5/5] format --- src/glint/flag/constraint.gleam | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/glint/flag/constraint.gleam b/src/glint/flag/constraint.gleam index 0130d7d..91e370a 100644 --- a/src/glint/flag/constraint.gleam +++ b/src/glint/flag/constraint.gleam @@ -20,14 +20,14 @@ pub fn one_of(allowed: List(a)) -> Constraint(a) { False -> snag.error( "invalid value '" - <> string.inspect(val) - <> "', must be one of: [" - <> { - allowed - |> list.map(fn(a) { "'" <> string.inspect(a) <> "'" }) - |> string.join(", ") - } - <> "]", + <> string.inspect(val) + <> "', must be one of: [" + <> { + allowed + |> list.map(fn(a) { "'" <> string.inspect(a) <> "'" }) + |> string.join(", ") + } + <> "]", ) } } @@ -43,16 +43,16 @@ pub fn none_of(disallowed: List(a)) -> Constraint(a) { True -> snag.error( "invalid value '" - <> string.inspect(val) - <> "', must not be one of: [" - <> { - { - disallowed - |> list.map(fn(a) { "'" <> string.inspect(a) <> "'" }) - |> string.join(", ") - <> "]" - } - }, + <> string.inspect(val) + <> "', must not be one of: [" + <> { + { + disallowed + |> list.map(fn(a) { "'" <> string.inspect(a) <> "'" }) + |> string.join(", ") + <> "]" + } + }, ) } }