From 67f6692fa119b8ee02c6a05a869d54411179dd15 Mon Sep 17 00:00:00 2001 From: Joe Eli McIlvain Date: Fri, 30 Aug 2024 15:01:10 -0700 Subject: [PATCH] Initial commit. --- README.md | 4 +- manifest.savi | 22 ++++++++ spec/CLI.Spec.savi | 98 ++++++++++++++++++++++++++++++++++ spec/Main.savi | 5 ++ src/CLI.Error.savi | 6 +++ src/CLI.Option.savi | 120 +++++++++++++++++++++++++++++++++++++++++ src/CLI.Parser.savi | 126 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 manifest.savi create mode 100644 spec/CLI.Spec.savi create mode 100644 spec/Main.savi create mode 100644 src/CLI.Error.savi create mode 100644 src/CLI.Option.savi create mode 100644 src/CLI.Parser.savi diff --git a/README.md b/README.md index 16476b8..c7b5711 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -A base repository for Savi language libraries, with common CI actions configured. +# CLI -See the [Guide](https://github.com/savi-lang/base-standard-library/wiki/Guide) for details on how it works and how to use it for your own libraries. +A simple utility library for creating command-line interfaces in Savi. diff --git a/manifest.savi b/manifest.savi new file mode 100644 index 0000000..1f58ad3 --- /dev/null +++ b/manifest.savi @@ -0,0 +1,22 @@ +:manifest lib CLI + :sources "src/*.savi" + + :dependency Map v0 + :from "github:savi-lang/Map" + +:manifest bin "spec" + :copies CLI + :sources "spec/*.savi" + + :dependency Spec v0 + :from "github:savi-lang/Spec" + :depends on Map + :depends on Time + :depends on Timer + + :transitive dependency Time v0 + :from "github:savi-lang/Time" + + :transitive dependency Timer v0 + :from "github:savi-lang/Timer" + :depends on Time diff --git a/spec/CLI.Spec.savi b/spec/CLI.Spec.savi new file mode 100644 index 0000000..34f965a --- /dev/null +++ b/spec/CLI.Spec.savi @@ -0,0 +1,98 @@ +:class CLI.Spec + :is Spec + :const describes: "CLI" + + :it "can define options and parse arguments against them" + defs = CLI.Option.Defs.new + + defs.flag(False, "verbose", 'v') + defs.i64(0, "min", 'm') + defs.i64(100, "max", 'M') + defs.f64(1.0, "red", 'r') + defs.f64(1.0, "green", 'g') + defs.f64(1.0, "blue", 'b') + defs.string("Alice", "name", 'n') + + try ( + cli = CLI.Parser.parse!(defs, []) + assert: cli.options.flag["verbose"]! == False + assert: cli.options.i64["min"]! == 0 + assert: cli.options.i64["max"]! == 100 + assert: cli.options.f64["red"]! == 1.0 + assert: cli.options.f64["green"]! == 1.0 + assert: cli.options.f64["blue"]! == 1.0 + assert: cli.options.string["name"]! == "Alice" + assert: cli.positional_args == [] + assert: cli.trailing_args == [] + | error | + assert: error.message == "no error" + ) + + try ( + cli = CLI.Parser.parse!(defs, [ + "-vr", "0.5", "--green", "0.1", "--min=10", "-M", "200", "-n=Bob=Bibble" + ]) + assert: cli.options.flag["verbose"]! == True + assert: cli.options.i64["min"]! == 10 + assert: cli.options.i64["max"]! == 200 + assert: cli.options.f64["red"]! == 0.5 + assert: cli.options.f64["green"]! == 0.1 + assert: cli.options.f64["blue"]! == 1.0 + assert: cli.options.string["name"]! == "Bob=Bibble" + assert: cli.positional_args == [] + assert: cli.trailing_args == [] + | error | + assert: error.message == "no error" + ) + + try ( + cli = CLI.Parser.parse!(defs, [ + "foo", "-v", "bar", "baz", "--min=10", "blah", "--", "trail", "ing" + ]) + assert: cli.options.flag["verbose"]! == True + assert: cli.options.i64["min"]! == 10 + assert: cli.positional_args == ["foo", "bar", "baz", "blah"] + assert: cli.trailing_args == ["trail", "ing"] + ) + + try ( + cli = CLI.Parser.parse!(defs, ["--bogus"]) + assert: "should have" == "errored" + | error CLI.Error | + assert: error.message == "unknown option: bogus" + ) + + try ( + cli = CLI.Parser.parse!(defs, ["--verbose", "--min"]) + assert: "should have" == "errored" + | error CLI.Error | + assert: error.message == "missing value for option: min" + ) + + try ( + cli = CLI.Parser.parse!(defs, ["--min", "--verbose"]) + assert: "should have" == "errored" + | error CLI.Error | + assert: error.message == "missing value for option: min" + ) + + try ( + cli = CLI.Parser.parse!(defs, ["-vm"]) + assert: "should have" == "errored" + | error CLI.Error | + assert: error.message == "missing value for option: min" + ) + + try ( + cli = CLI.Parser.parse!(defs, ["-mv"]) + assert: "should have" == "errored" + | error CLI.Error | + assert: error.message == "missing value for option: min" + ) + + try ( + cli = CLI.Parser.parse!(defs, ["-="]) + assert: "should have" == "errored" + | error CLI.Error | + assert: error.message == "invalid argument: -=" + ) diff --git a/spec/Main.savi b/spec/Main.savi new file mode 100644 index 0000000..5ab5c13 --- /dev/null +++ b/spec/Main.savi @@ -0,0 +1,5 @@ +:actor Main + :new (env Env) + Spec.Process.run(env, [ + Spec.Run(CLI.Spec).new(env) + ]) diff --git a/src/CLI.Error.savi b/src/CLI.Error.savi new file mode 100644 index 0000000..1398361 --- /dev/null +++ b/src/CLI.Error.savi @@ -0,0 +1,6 @@ +:class val CLI.Error + :let path String + :let message String + :new _new(@path, @message) + :fun non new(path Array(String)'box, message) + @_new(String.join(path, "->"), message) diff --git a/src/CLI.Option.savi b/src/CLI.Option.savi new file mode 100644 index 0000000..3592979 --- /dev/null +++ b/src/CLI.Option.savi @@ -0,0 +1,120 @@ +// For now we have an imperative API for creating CLI argument parsers, +// but in the future we will create a declarator-based API to replace this. +// Savi doesn't yet support the specific things we want to do with declarators. +// +// Alternatively we could use `TraceData` and extend it to support things +// like doc strings, which could let us leverage that existing mechanism. + +// TODO: Use this alias when Savi compiler is fixed to allow it. +// :alias CLI.Option.Type: (Bool | I64 | F64 | String) + +:trait CLI.Option.Def.Any + :fun name String + +:class CLI.Option.Def(T IntoString'val) // TODO: use `CLI.Option.Type` when Savi supports it. + :is CLI.Option.Def.Any + :let default T + :let name String + :let short_char U32 + :new (@default, @name, @short_char = 0) + + :fun _parse_value!(path Array(String)'ref, value_string String): T + case T <: ( + | Bool | + value_string != "false" && + value_string != "False" && + value_string != "FALSE" && + value_string != "0" + | I64 | + try (value_string.parse_i64! | + error! CLI.Error.new(path, "not a valid integer value: \(value_string)") + ) + | F64 | + try (value_string.parse_f64! | + error! CLI.Error.new(path, "not a valid floating-point value: \(value_string)") + ) + | String | + value_string + | + error! CLI.Error.new(path, "unsupported option type: \(reflection_of_type T)") + ) + +:class CLI.Option.Defs + :let _all_defs: Map(String, CLI.Option.Def.Any).new + :let _all_short_defs: Map(U32, CLI.Option.Def.Any).new + :let flag_defs: Map(String, CLI.Option.Def(Bool)).new + :let i64_defs: Map(String, CLI.Option.Def(I64)).new + :let f64_defs: Map(String, CLI.Option.Def(F64)).new + :let string_defs: Map(String, CLI.Option.Def(String)).new + + :fun _get_def!(path, name String) + :errors CLI.Error + try (@_all_defs[name]! | + error! CLI.Error.new(path, "unknown option: \(name)") + ) + + :fun _get_def_by_short_char!(path, short_char U32) + :errors CLI.Error + try (@_all_short_defs[short_char]! | + error! CLI.Error.new(path, "unknown short option: \(String.new.push_utf8(short_char))") + ) + + :fun ref flag(default, name String, short_char = 0) + @_all_defs[name] = @flag_defs[name] = def = + CLI.Option.Def(Bool).new(default, name, short_char) + if short_char.is_nonzero (@_all_short_defs[short_char] = def) + @ + + :fun ref i64(default, name String, short_char = 0) + @_all_defs[name] = @i64_defs[name] = def = + CLI.Option.Def(I64).new(default, name, short_char) + if short_char.is_nonzero (@_all_short_defs[short_char] = def) + @ + + :fun ref f64(default, name String, short_char = 0) + @_all_defs[name] = @f64_defs[name] = def = + CLI.Option.Def(F64).new(default, name, short_char) + if short_char.is_nonzero (@_all_short_defs[short_char] = def) + @ + + :fun ref string(default, name String, short_char = 0) + @_all_defs[name] = @string_defs[name] = def = + CLI.Option.Def(String).new(default, name, short_char) + if short_char.is_nonzero (@_all_short_defs[short_char] = def) + @ + +:class CLI.Options + :let flag: Map(String, Bool).new + :let i64: Map(String, I64).new + :let f64: Map(String, F64).new + :let string: Map(String, String).new + + :fun ref _capture_arg!(path, def CLI.Option.Def.Any, value_string) + :errors CLI.Error + try ( + case def <: ( + | CLI.Option.Def(Bool) | + @flag[def.name] = + def._parse_value!(path, value_string).as!(Bool) // TODO: as! shouldn't be needed here + | CLI.Option.Def(I64) | + @i64[def.name] = + def._parse_value!(path, value_string).as!(I64) // TODO: as! shouldn't be needed here + | CLI.Option.Def(F64) | + @f64[def.name] = + def._parse_value!(path, value_string).as!(F64) // TODO: as! shouldn't be needed here + | CLI.Option.Def(String) | + @string[def.name] = + def._parse_value!(path, value_string).as!(String) // TODO: as! shouldn't be needed here + | + error! + ) + | error (CLI.Error | None) | + error! error if error <: CLI.Error + error! CLI.Error.new(path, "unsupported option type") + ) + + :fun ref _prefill_defaults_from(defs CLI.Option.Defs) + defs.flag_defs.each -> (name, def | @flag[name] = def.default) + defs.i64_defs.each -> (name, def | @i64[name] = def.default) + defs.f64_defs.each -> (name, def | @f64[name] = def.default) + defs.string_defs.each -> (name, def | @string[name] = def.default) diff --git a/src/CLI.Parser.savi b/src/CLI.Parser.savi new file mode 100644 index 0000000..06012fb --- /dev/null +++ b/src/CLI.Parser.savi @@ -0,0 +1,126 @@ +// For now, this implementation is one big tangled function and I ain't proud +// of it, but it's good enough for now (this initial CLI parser is just a +// means to an end), and there's room to refactor later to match the same spec. + +:class CLI.Parser + :let defs CLI.Option.Defs + :let options: CLI.Options.new + :let positional_args: Array(String).new + :let trailing_args: Array(String).new + + :new parse!(@defs, args Array(String)'box) + :errors CLI.Error + last_option_name = "" + now_trailing = False + path = Array(String).new + + @options._prefill_defaults_from(@defs) + + args.each -> (arg | + // Handle trailing arguments, when in the trailing state. + if now_trailing ( + @trailing_args << arg + next + ) + + // Handle the case where we have an option already waiting for a value. + if last_option_name.is_not_empty ( + def = @defs._get_def!(path, last_option_name) + if arg.starts_with("-") ( + error! CLI.Error.new(path, "missing value for option: \(last_option_name)") + | + path << def.name + @options._capture_arg!(path, def, arg) + try path.pop! + ) + last_option_name = "" + next + ) + + case ( + | arg.starts_with("--") | + arg = arg.trim(2) + + // Break the outer loop if we encounter a double dash alone. + // This means everything else will be trailing args. + if arg.is_empty ( + now_trailing = True + next + ) + + // Split the argument at `=` to separate name from value. + try ( + pair = arg.split2!('=') + name = pair.head + value_string = pair.tail + | + // If there is no `=`, we will expect a value later, except boolean, + // which gets treated as "True" when present with no `=` value. + def = @defs._get_def!(path, arg) + if def <: CLI.Option.Def(Bool) ( + @options.flag[def.name] = True + | + last_option_name = arg + ) + next + ) + + // Find the corresponding definition and capture the argument. + def = @defs._get_def!(path, name) + path << name + @options._capture_arg!(path, def, value_string) + try path.pop! + + | arg.starts_with("-") | + error! CLI.Error.new(path, "invalid argument: -") if arg.size <= 1 + + // Iterate over each character in the short option arg string. + arg.each_char_with_index_and_width(1) -> (char, index, width | + // If we encounter an `=`, we expect that the rest here is the value. + if char == '=' ( + if last_option_name.is_empty ( + error! CLI.Error.new(path, "invalid argument: \(arg)") + ) + + name = last_option_name + value_string = arg.trim(index + 1) + def = @defs._get_def!(path, name) + path << name + @options._capture_arg!(path, def, value_string) + try path.pop! + + last_option_name = "" + break arg + ) + + // Handle the case where we have an option already waiting for a value. + if last_option_name.is_not_empty ( + error! CLI.Error.new(path, "missing value for option: \(last_option_name)") + ) + + // Find the corresponding definition for this character. + def = @defs._get_def_by_short_char!(path, char) + + // Now we are waiting for this new option to get a value, unless + // it is a boolean, in which case we assign true to it. + if def <: CLI.Option.Def(Bool) ( + @options.flag[def.name] = True + | + last_option_name = def.name + ) + ) + | + @positional_args << arg + ) + ) + + // At the end of the arguments list, handle the case where + // we still have an option waiting for a value. + if last_option_name.is_not_empty ( + waiting_def = @defs._get_def!(path, last_option_name) + if waiting_def <: CLI.Option.Def(Bool) ( + @options.flag[waiting_def.name] = True + | + error! CLI.Error.new(path, "missing value for option: \(last_option_name)") + ) + )