Skip to content

Commit

Permalink
Merge pull request #1 from savi-lang/add/cli
Browse files Browse the repository at this point in the history
Initial commit with basic CLI specification and parsing
  • Loading branch information
jemc authored Aug 31, 2024
2 parents c0ece23 + 67f6692 commit 55f9dd2
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 2 deletions.
4 changes: 2 additions & 2 deletions README.md
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.
22 changes: 22 additions & 0 deletions manifest.savi
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
98 changes: 98 additions & 0 deletions spec/CLI.Spec.savi
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: -="
)
5 changes: 5 additions & 0 deletions spec/Main.savi
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)
])
6 changes: 6 additions & 0 deletions src/CLI.Error.savi
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)
120 changes: 120 additions & 0 deletions src/CLI.Option.savi
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)
126 changes: 126 additions & 0 deletions src/CLI.Parser.savi
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)")
)
)

0 comments on commit 55f9dd2

Please sign in to comment.