Skip to content

Commit

Permalink
Merge pull request #2 from savi-lang/add/strongly-typed-trace-data-mu…
Browse files Browse the repository at this point in the history
…table

Use new `TraceData.Mutable` to have strongly typed `CLI.Option.Data`.
  • Loading branch information
jemc authored Sep 7, 2024
2 parents 55f9dd2 + 7178012 commit 8b42b33
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 8b42b33

Please sign in to comment.