diff --git a/spec/CLI.Spec.savi b/spec/CLI.Spec.savi index 34f965a..5644dd3 100644 --- a/spec/CLI.Spec.savi +++ b/spec/CLI.Spec.savi @@ -1,27 +1,55 @@ +: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 | @@ -29,16 +57,16 @@ ) 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 | @@ -46,52 +74,64 @@ ) 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: -=" diff --git a/src/CLI.Option.savi b/src/CLI.Option.savi index 3592979..c8047c8 100644 --- a/src/CLI.Option.savi +++ b/src/CLI.Option.savi @@ -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 @@ -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 diff --git a/src/CLI.Parser.savi b/src/CLI.Parser.savi index 06012fb..a9a68f8 100644 --- a/src/CLI.Parser.savi +++ b/src/CLI.Parser.savi @@ -2,19 +2,19 @@ // 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 +:class CLI.Parser(T CLI.Option.Data) + :let options T + :let _defs: CLI.Option.Defs.new :let positional_args: Array(String).new :let trailing_args: Array(String).new - :new parse!(@defs, args Array(String)'box) + :new parse!(@options, args Array(String)'box) :errors CLI.Error last_option_name = "" now_trailing = False path = Array(String).new - @options._prefill_defaults_from(@defs) + @options.define_cli_options(@_defs) args.each -> (arg | // Handle trailing arguments, when in the trailing state. @@ -25,12 +25,12 @@ // 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) + 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) + @_capture_arg!(path, def, arg) try path.pop! ) last_option_name = "" @@ -56,9 +56,9 @@ | // 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) + def = @_defs._get_def!(path, arg) if def <: CLI.Option.Def(Bool) ( - @options.flag[def.name] = True + @_capture_bool_true!(path, def.name) | last_option_name = arg ) @@ -66,9 +66,9 @@ ) // Find the corresponding definition and capture the argument. - def = @defs._get_def!(path, name) + def = @_defs._get_def!(path, name) path << name - @options._capture_arg!(path, def, value_string) + @_capture_arg!(path, def, value_string) try path.pop! | arg.starts_with("-") | @@ -84,9 +84,9 @@ name = last_option_name value_string = arg.trim(index + 1) - def = @defs._get_def!(path, name) + def = @_defs._get_def!(path, name) path << name - @options._capture_arg!(path, def, value_string) + @_capture_arg!(path, def, value_string) try path.pop! last_option_name = "" @@ -99,12 +99,12 @@ ) // Find the corresponding definition for this character. - def = @defs._get_def_by_short_char!(path, char) + 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 + @_capture_bool_true!(path, def.name) | last_option_name = def.name ) @@ -117,10 +117,27 @@ // 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) + waiting_def = @_defs._get_def!(path, last_option_name) if waiting_def <: CLI.Option.Def(Bool) ( - @options.flag[waiting_def.name] = True + @_capture_bool_true!(path, waiting_def.name) | error! CLI.Error.new(path, "missing value for option: \(last_option_name)") ) ) + + :fun ref _capture_arg!(path, def CLI.Option.Def.Any, value_string) + :errors CLI.Error + options = try ( + @options.as!(TraceData.Mutable) // TODO: as! should not be needed + | + error! CLI.Error.new(path, "options are not TraceData.Mutable") // how?? + ) + + def._inject!(path, value_string, options) + + :fun ref _capture_bool_true!(path Array(String)'box, name String) + mut = _Inject(Bool).new(name, True) + @options.trace_data_mutable(mut) + if mut.errors.is_not_empty ( + error! CLI.Error.new(path, String.join(mut.errors, ", ")) + ) diff --git a/src/_Inject.savi b/src/_Inject.savi new file mode 100644 index 0000000..ee1ddfb --- /dev/null +++ b/src/_Inject.savi @@ -0,0 +1,48 @@ +:trait _InjectAny + :is TraceData.Mutator + :let errors Array(String): [] + +:class _Inject(T val) + :is _InjectAny + + :var halt Bool: False + :let errors Array(String): [] + + :let key String + :let value T + :new (@key, @value) + + :fun ref object(recurse_id USize) None + :yields String for None + return if @halt + @halt = True + + yield @key + + :fun ref object_key_is_invalid None + @errors << "\(@key) is not a valid key" + + :fun ref object_key_cannot_be_modified None + @errors << "\(@key) is not a CLI-parseable type" + + :fun ref array(recurse_id USize, size USize) None + :yields USize for None + :fun ref array_truncate(recurse_id USize, size USize) None + :yields USize for None + :fun ref array_index_is_invalid None + :fun ref array_index_cannot_be_modified None + + :fun ref set_none: v = @value, if v <: None (yield None) + :fun ref set_bool: v = @value, if v <: Bool (yield Bool[v]) + :fun ref set_u64: v = @value, if v <: U64 (yield U64[v]) + :fun ref set_u32: v = @value, if v <: U32 (yield U32[v]) + :fun ref set_u16: v = @value, if v <: U16 (yield U16[v]) + :fun ref set_u8: v = @value, if v <: U8 (yield U8[v]) + :fun ref set_i64: v = @value, if v <: I64 (yield I64[v]) + :fun ref set_i32: v = @value, if v <: I32 (yield I32[v]) + :fun ref set_i16: v = @value, if v <: I16 (yield I16[v]) + :fun ref set_i8: v = @value, if v <: I8 (yield I8[v]) + :fun ref set_f64: v = @value, if v <: F64 (yield F64[v]) + :fun ref set_f32: v = @value, if v <: F32 (yield F32[v]) + :fun ref set_bytes: v = @value, if v <: Bytes (yield v) + :fun ref set_string: v = @value, if v <: String (yield v)