Skip to content

Commit

Permalink
Use new TraceData.Mutable to have strongly typed CLI.Option.Data.
Browse files Browse the repository at this point in the history
This is more ergonomic, and closer to the eventual desired state
of using declarators for these properties.
  • Loading branch information
jemc committed Sep 7, 2024
1 parent 67f6692 commit 7178012
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 137 deletions.
108 changes: 74 additions & 34 deletions spec/CLI.Spec.savi
Original file line number Diff line number Diff line change
@@ -1,97 +1,137 @@
:class _ExampleCLIOptions
:var verbose Bool: False
:var min I64: 0
:var max I64: 100
:var red F64: 1.0
:var green F64: 1.0
:var blue F64: 1.0
:var name String: "Alice"

:is TraceData.Mutable
:fun ref trace_data_mutable(trace TraceData.Mutator) None
trace.object(identity_digest_of @) -> (key |
case key == (
| "verbose" | trace.replace_bool(@verbose) -> (v | @verbose = v)
| "min" | trace.replace_i64(@min) -> (v | @min = v)
| "max" | trace.replace_i64(@max) -> (v | @max = v)
| "red" | trace.replace_f64(@red) -> (v | @red = v)
| "green" | trace.replace_f64(@green) -> (v | @green = v)
| "blue" | trace.replace_f64(@blue) -> (v | @blue = v)
| "name" | trace.replace_string(@name) -> (v | @name = v)
)
)

// TODO: We eventually want to use custom declarators and/or docstrings
// to generate the CLI help text and definitions. But for now, we define
// them with an imperative trace method.
:is CLI.Option.Data
:fun non define_cli_options(defs CLI.Option.Defs) None
defs.bool("verbose", 'v')
defs.i64("min", 'm')
defs.i64("max", 'M')
defs.f64("red", 'r')
defs.f64("green", 'g')
defs.f64("blue", 'b')
defs.string("name", 'n')

: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')
opts = _ExampleCLIOptions.new

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"
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [])
assert: cli.options.verbose == False
assert: cli.options.min == 0
assert: cli.options.max == 100
assert: cli.options.red == 1.0
assert: cli.options.green == 1.0
assert: cli.options.blue == 1.0
assert: cli.options.name == "Alice"
assert: cli.positional_args == []
assert: cli.trailing_args == []
| error |
assert: error.message == "no error"
)

try (
cli = CLI.Parser.parse!(defs, [
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"-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.options.verbose == True
assert: cli.options.min == 10
assert: cli.options.max == 200
assert: cli.options.red == 0.5
assert: cli.options.green == 0.1
assert: cli.options.blue == 1.0
assert: cli.options.name == "Bob=Bibble"
assert: cli.positional_args == []
assert: cli.trailing_args == []
| error |
assert: error.message == "no error"
)

try (
cli = CLI.Parser.parse!(defs, [
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"foo", "-v", "bar", "baz", "--min=10", "blah", "--", "trail", "ing"
])
assert: cli.options.flag["verbose"]! == True
assert: cli.options.i64["min"]! == 10
assert: cli.options.verbose == True
assert: cli.options.min == 10
assert: cli.positional_args == ["foo", "bar", "baz", "blah"]
assert: cli.trailing_args == ["trail", "ing"]
)

try (
cli = CLI.Parser.parse!(defs, ["--bogus"])
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"--bogus"
])
assert: "should have" == "errored"
| error CLI.Error |
assert: error.message == "unknown option: bogus"
)

try (
cli = CLI.Parser.parse!(defs, ["--verbose", "--min"])
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"--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"])
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"--min", "--verbose"
])
assert: "should have" == "errored"
| error CLI.Error |
assert: error.message == "missing value for option: min"
)

try (
cli = CLI.Parser.parse!(defs, ["-vm"])
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"-vm"
])
assert: "should have" == "errored"
| error CLI.Error |
assert: error.message == "missing value for option: min"
)

try (
cli = CLI.Parser.parse!(defs, ["-mv"])
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"-mv"
])
assert: "should have" == "errored"
| error CLI.Error |
assert: error.message == "missing value for option: min"
)

try (
cli = CLI.Parser.parse!(defs, ["-="])
cli = CLI.Parser(_ExampleCLIOptions).parse!(_ExampleCLIOptions.new, [
"-="
])
assert: "should have" == "errored"
| error CLI.Error |
assert: error.message == "invalid argument: -="
Expand Down
165 changes: 79 additions & 86 deletions src/CLI.Option.savi
Original file line number Diff line number Diff line change
@@ -1,51 +1,81 @@
// 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
:fun short_char U32
:fun _inject!(path Array(String)'box, value_string String, options TraceData.Mutable) None
:errors CLI.Error

:class CLI.Option.Def(T IntoString'val) // TODO: use `CLI.Option.Type` when Savi supports it.
:class CLI.Option.Def(T IntoString'val)
:is CLI.Option.Def.Any
:let default T
:let name String
:let short_char U32
:new (@default, @name, @short_char = 0)
:new (@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)")
:fun _inject!(path Array(String)'box, value_string String, options TraceData.Mutable) None
:errors CLI.Error
mut _InjectAny = try (
case T <: (
| Bool | _Inject(Bool) .new(@name, @_parse_bool!(path, value_string))
| U64 | _Inject(U64) .new(@name, @_parse_i64!(path, value_string).u64)
| U32 | _Inject(U32) .new(@name, @_parse_i64!(path, value_string).u32!)
| U16 | _Inject(U16) .new(@name, @_parse_i64!(path, value_string).u16!)
| U8 | _Inject(U8) .new(@name, @_parse_i64!(path, value_string).u8!)
| I64 | _Inject(I64) .new(@name, @_parse_i64!(path, value_string))
| I32 | _Inject(I32) .new(@name, @_parse_i64!(path, value_string).i32!)
| I16 | _Inject(I16) .new(@name, @_parse_i64!(path, value_string).i16!)
| I8 | _Inject(I8) .new(@name, @_parse_i64!(path, value_string).i8!)
| F64 | _Inject(F64) .new(@name, @_parse_f64!(path, value_string))
| F32 | _Inject(F32) .new(@name, @_parse_f32!(path, value_string).f32)
| String | _Inject(String).new(@name, value_string)
|
error! CLI.Error.new(path, "unsupported option type: \(reflection_of_type T)")
)
| String |
value_string
|
error! CLI.Error.new(path, "unsupported option type: \(reflection_of_type T)")
| error (CLI.Error | None) |
error! error if error <: CLI.Error
error! CLI.Error.new(path, "value out of bounds for \(reflection_of_type T): \(value_string)")
)

options.trace_data_mutable(mut)
if mut.errors.is_not_empty (
error! CLI.Error.new(path, String.join(mut.errors, ", "))
)

:fun _parse_bool!(path, value_string String) Bool
case value_string == (
| "true" | True | "false" | False
| "True" | True | "False" | False
| "TRUE" | True | "FALSE" | False
| "t" | True | "f" | False
| "yes" | True | "no" | False
| "Yes" | True | "No" | False
| "YES" | True | "NO" | False
| "y" | True | "n" | False
| "Y" | True | "N" | False
| "1" | True | "0" | False
| "on" | True | "off" | False
| "On" | True | "Off" | False
| "ON" | True | "OFF" | False
| error! CLI.Error.new(path, "not a valid boolean value: \(value_string)")
)

:fun _parse_i64!(path, value_string String) I64
:errors CLI.Error
try (value_string.parse_i64! |
error! CLI.Error.new(path, "not a valid integer value: \(value_string)")
)

:fun _parse_f64!(path, value_string String) F64
:errors CLI.Error
try (value_string.parse_f64! |
error! CLI.Error.new(path, "not a valid floating-point value: \(value_string)")
)

: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
Expand All @@ -59,62 +89,25 @@
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)
:fun ref _define(def CLI.Option.Def.Any)
@_all_defs[def.name] = def
if def.short_char.is_nonzero (@_all_short_defs[def.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 bool(name, short_char = 0): @_define(CLI.Option.Def(Bool).new(name, short_char))
:fun ref u64(name, short_char = 0): @_define(CLI.Option.Def(U64).new(name, short_char))
:fun ref u32(name, short_char = 0): @_define(CLI.Option.Def(U32).new(name, short_char))
:fun ref u16(name, short_char = 0): @_define(CLI.Option.Def(U16).new(name, short_char))
:fun ref u8(name, short_char = 0): @_define(CLI.Option.Def(U8).new(name, short_char))
:fun ref i64(name, short_char = 0): @_define(CLI.Option.Def(I64).new(name, short_char))
:fun ref i32(name, short_char = 0): @_define(CLI.Option.Def(I32).new(name, short_char))
:fun ref i16(name, short_char = 0): @_define(CLI.Option.Def(I16).new(name, short_char))
:fun ref i8(name, short_char = 0): @_define(CLI.Option.Def(I8).new(name, short_char))
:fun ref f64(name, short_char = 0): @_define(CLI.Option.Def(F64).new(name, short_char))
:fun ref f32(name, short_char = 0): @_define(CLI.Option.Def(F32).new(name, short_char))
:fun ref string(name, short_char = 0): @_define(CLI.Option.Def(String).new(name, short_char))

: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")
)
:trait ref CLI.Option.Data
:is TraceData.Mutable

: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)
:fun non define_cli_options(def CLI.Option.Defs) None
Loading

0 comments on commit 7178012

Please sign in to comment.