-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from savi-lang/add/cli
Initial commit with basic CLI specification and parsing
- Loading branch information
Showing
7 changed files
with
379 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: -=" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
:actor Main | ||
:new (env Env) | ||
Spec.Process.run(env, [ | ||
Spec.Run(CLI.Spec).new(env) | ||
]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)") | ||
) | ||
) |