diff --git a/argparse/README.mbt.md b/argparse/README.mbt.md new file mode 100644 index 000000000..ad12c46ed --- /dev/null +++ b/argparse/README.mbt.md @@ -0,0 +1,544 @@ +# moonbitlang/core/argparse + +Declarative argument parsing for MoonBit. + +This package is inspired by [`clap`](https://github.com/clap-rs/clap) and keeps a +small, predictable feature set. + +`long` defaults to the argument name. Pass `long=""` to disable long alias. +Argument constructors are named `FlagArg` / `OptionArg` / `PositionArg` to +avoid shadowing built-in types like `Option`. + + +## Quick Start + +For tests, snapshotting the failure message is the recommended way to cover +both the parse error and the full contextual help text. + +```mbt check +///| +test "basic option + positional success snapshot" { + let matches = @argparse.parse( + Command("demo", options=[OptionArg("name")], positionals=[ + PositionArg("target"), + ]), + argv=["--name", "alice", "file.txt"], + env={}, + ) + @debug.debug_inspect( + matches.values, + content=( + #|{ "name": ["alice"], "target": ["file.txt"] } + ), + ) +} + +///| +test "basic option + positional failure snapshot" { + let cmd = @argparse.Command("demo", options=[OptionArg("name")], positionals=[ + PositionArg("target"), + ]) + try cmd.parse(argv=["--bad"], env={}) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo [options] [target] + #| + #|Arguments: + #| target + #| + #|Options: + #| -h, --help Show help information. + #| --name + #| + ), + ) + } noraise { + _ => panic() + } +} +``` + +### Using it for async CLI + +You can call `cmd.parse()` directly inside `async fn main`. +On parse failure, argparse automatically prints the error message with full +contextual help text. + +```mbt nocheck +///| +async fn main { + let cmd = @argparse.Command("demo", options=[@argparse.OptionArg("name")], positionals=[ + @argparse.PositionArg("target"), + ]) + let _ = cmd.parse() +} +``` + + +## Flags And Negation + +`flags` stay as `Map[String, Bool]`, so negated flags preserve explicit `false` +states. + +```mbt check +///| +test "negatable flag success snapshot" { + let cmd = @argparse.Command("demo", flags=[FlagArg("cache", negatable=true)]) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| + ), + ) + + let parsed = try! cmd.parse(argv=["--no-cache"], env={}) + @debug.debug_inspect( + parsed.flags, + content=( + #|{ "cache": false } + ), + ) +} +``` + +## Subcommands And Globals + +```mbt check +///| +test "global count flag success snapshot" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[Command("run")], + ) + + let parsed = try! cmd.parse(argv=["-v", "run", "-v"], env={}) + @debug.debug_inspect( + parsed.flag_counts, + content=( + #|{ "verbose": 2 } + ), + ) + guard parsed.subcommand is Some(("run", child)) + @debug.debug_inspect( + child.flag_counts, + content=( + #|{ "verbose": 2 } + ), + ) +} + +///| +test "subcommand context failure snapshot" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[Command("run")], + ) + try cmd.parse(argv=["run", "--oops"], env={}) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} +``` + +## Value Sources (argv > env > default_values) + +Value precedence is `argv > env > default_values`. + +```mbt check +///| +test "value source precedence snapshots" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("level", env="LEVEL", default_values=["1"]), + ]) + + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --level [env: LEVEL] [default: 1] + #| + ), + ) + + let from_default = try! cmd.parse(argv=[], env={}) + @debug.debug_inspect( + from_default.values, + content=( + #|{ "level": ["1"] } + ), + ) + @debug.debug_inspect( + from_default.sources, + content=( + #|{ "level": Default } + ), + ) + + let from_env = try! cmd.parse(argv=[], env={ "LEVEL": "2" }) + @debug.debug_inspect( + from_env.values, + content=( + #|{ "level": ["2"] } + ), + ) + @debug.debug_inspect( + from_env.sources, + content=( + #|{ "level": Env } + ), + ) + + let from_argv = try! cmd.parse(argv=["--level", "3"], env={ "LEVEL": "2" }) + @debug.debug_inspect( + from_argv.values, + content=( + #|{ "level": ["3"] } + ), + ) + @debug.debug_inspect( + from_argv.sources, + content=( + #|{ "level": Argv } + ), + ) +} +``` + +## Input Forms + +```mbt check +///| +test "option input forms snapshot" { + let cmd = @argparse.Command("demo", options=[OptionArg("count", short='c')]) + + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) + + let long_split = try! cmd.parse(argv=["--count", "2"], env={}) + @debug.debug_inspect( + long_split.values, + content=( + #|{ "count": ["2"] } + ), + ) + + let long_inline = try! cmd.parse(argv=["--count=3"], env={}) + @debug.debug_inspect( + long_inline.values, + content=( + #|{ "count": ["3"] } + ), + ) + + let short_split = try! cmd.parse(argv=["-c", "4"], env={}) + @debug.debug_inspect( + short_split.values, + content=( + #|{ "count": ["4"] } + ), + ) + + let short_attached = try! cmd.parse(argv=["-c5"], env={}) + @debug.debug_inspect( + short_attached.values, + content=( + #|{ "count": ["5"] } + ), + ) +} + +///| +test "double-dash separator snapshot" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let parsed = try! cmd.parse(argv=["--", "--x", "-y"], env={}) + @debug.debug_inspect( + parsed.values, + content=( + #|{ "tail": ["--x", "-y"] } + ), + ) +} +``` + +## Constraints And Policies + +`parse` raises a single display-ready error string that includes the error and +full contextual help. + +```mbt check +///| +test "requires relationship success and failure snapshots" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("mode", requires=["config"]), + OptionArg("config"), + ]) + + let ok = try! cmd.parse(argv=["--mode", "fast", "--config", "cfg.toml"], env={}) + @debug.debug_inspect( + ok.values, + content=( + #|{ "mode": ["fast"], "config": ["cfg.toml"] } + ), + ) + + try cmd.parse(argv=["--mode", "fast"], env={}) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'config' (required by 'mode') + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| --config + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "arg group required and exclusive failure snapshot" { + let cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=["fast", "slow"]), + ], + flags=[FlagArg("fast"), FlagArg("slow")], + ) + + try cmd.parse(argv=[], env={}) catch { + err => + inspect( + err, + content=( + #|error: the following required arguments were not provided: + #| <--fast|--slow> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand required policy failure snapshot" { + let cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ + Command("echo"), + ]) + + try cmd.parse(argv=[], env={}) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'subcommand' + #| + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} +``` + +```mbt check +///| +test "conflicts_with success and failure snapshots" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", conflicts_with=["quiet"]), + FlagArg("quiet"), + ]) + + let ok = try! cmd.parse(argv=["--verbose"], env={}) + @debug.debug_inspect( + ok.flags, + content=( + #|{ "verbose": true } + ), + ) + + try cmd.parse(argv=["--verbose", "--quiet"], env={}) catch { + err => + inspect( + err, + content=( + #|error: conflicting arguments: verbose and quiet + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --quiet + #| + ), + ) + } noraise { + _ => panic() + } +} +``` + +## PositionArg Value Ranges + +Positionals are parsed in declaration order (no explicit index). + +```mbt check +///| +test "bounded non-last positional success snapshot" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let parsed = try! cmd.parse(argv=["a", "b", "c"], env={}) + @debug.debug_inspect( + parsed.values, + content=( + #|{ "first": ["a", "b"], "second": ["c"] } + ), + ) +} + +///| +test "bounded non-last positional failure snapshot" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + try cmd.parse(argv=["a", "b", "c", "d"], env={}) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'd' for '' found; no more were expected + #| + #|Usage: demo + #| + #|Arguments: + #| first... + #| second + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} +``` + +```mbt check +///| +let cmd : @argparse.Command = Command( + "wrap", + options=[OptionArg("config"), OptionArg("mode")], + positionals=[ + PositionArg( + "child_argv", + num_args=ValueRange(lower=0), + allow_hyphen_values=true, + ), + ], +) + +///| +test "positional passthrough keeps child argv after double-dash snapshot" { + let parsed = try! cmd.parse( + argv=[ + "--config", "cfg.toml", "--", "child", "--mode", "fast", "--", "--flag", + ], + env={}, + ) + @debug.debug_inspect( + parsed.values, + content=( + #|{ + #| "config": ["cfg.toml"], + #| "child_argv": ["child", "--mode", "fast", "--", "--flag"], + #|} + ), + ) +} + +///| +test "without separator outer parser still consumes its own option names snapshot" { + let parsed = try! cmd.parse( + argv=["--config", "cfg.toml", "child", "--mode", "fast"], + env={}, + ) + @debug.debug_inspect( + parsed.values, + content=( + #|{ "config": ["cfg.toml"], "mode": ["fast"], "child_argv": ["child"] } + ), + ) +} +``` diff --git a/argparse/arg_action.mbt b/argparse/arg_action.mbt new file mode 100644 index 000000000..2123cd5e6 --- /dev/null +++ b/argparse/arg_action.mbt @@ -0,0 +1,44 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn arg_min_max_for_validate(arg : Arg) -> (Int, Int?) raise ArgBuildError { + if arg.info is PositionalInfo(num_args=Some(range), ..) { + if range.lower < 0 { + raise Unsupported("min values must be >= 0") + } + if range.upper is Some(max_value) { + if max_value < 0 { + raise Unsupported("max values must be >= 0") + } + if max_value < range.lower { + raise Unsupported("max values must be >= min values") + } + if range.lower == 0 && max_value == 0 { + raise Unsupported("empty value range (0..0) is unsupported") + } + } + (range.lower, range.upper) + } else { + (0, None) + } +} + +///| +fn arg_min_max(arg : Arg) -> (Int, Int?) { + match arg.info { + PositionalInfo(num_args=Some(range), ..) => (range.lower, range.upper) + _ => (0, None) + } +} diff --git a/argparse/arg_group.mbt b/argparse/arg_group.mbt new file mode 100644 index 000000000..4821649d8 --- /dev/null +++ b/argparse/arg_group.mbt @@ -0,0 +1,60 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Declarative argument group constructor. +pub struct ArgGroup { + priv name : String + priv required : Bool + priv multiple : Bool + priv args : Array[String] + priv requires : Array[String] + priv conflicts_with : Array[String] + + /// Create an argument group. + fn new( + name : StringView, + required? : Bool, + multiple? : Bool, + args? : ArrayView[String], + requires? : ArrayView[String], + conflicts_with? : ArrayView[String], + ) -> ArgGroup +} + +///| +/// Create an argument group. +/// +/// Notes: +/// - `required=true` means at least one member of the group must be present. +/// - `multiple=false` means group members are mutually exclusive. +/// - `requires` and `conflicts_with` may reference either group names or +/// argument names. +pub fn ArgGroup::new( + name : StringView, + required? : Bool = false, + multiple? : Bool = true, + args? : ArrayView[String] = [], + requires? : ArrayView[String] = [], + conflicts_with? : ArrayView[String] = [], +) -> ArgGroup { + { + name: name.to_string(), + required, + multiple, + args: args.to_array(), + requires: requires.to_array(), + conflicts_with: conflicts_with.to_array(), + } +} diff --git a/argparse/arg_spec.mbt b/argparse/arg_spec.mbt new file mode 100644 index 000000000..2615486d5 --- /dev/null +++ b/argparse/arg_spec.mbt @@ -0,0 +1,300 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Behavior for flag args. +/// +/// - `SetTrue` / `SetFalse` set a boolean value. +/// - `Count` increments `Matches.flag_counts`. +/// - `Help` / `Version` display output and exit successfully when triggered. +pub(all) enum FlagAction { + SetTrue + SetFalse + Count + Help + Version +} derive(Eq, Show) + +///| +/// Behavior for option args. +/// +/// - `Set` keeps the last provided value. +/// - `Append` keeps all provided values in order. +pub(all) enum OptionAction { + Set + Append +} derive(Eq, Show) + +///| +/// Unified argument model used by the parser internals. +priv struct Arg { + // All + name : String + about : String? + env : String? + requires : Array[String] + conflicts_with : Array[String] + required : Bool + global : Bool + hidden : Bool + info : ArgInfo + multiple : Bool +} + +///| +priv enum ArgInfo { + FlagInfo( + short~ : Char?, + long~ : String?, + action~ : FlagAction, + negatable~ : Bool + ) + OptionInfo( + short~ : Char?, + long~ : String?, + action~ : OptionAction, + default_values~ : Array[String]?, + allow_hyphen_values~ : Bool + ) + PositionalInfo( + num_args~ : ValueRange?, + default_values~ : Array[String]?, + allow_hyphen_values~ : Bool + ) +} + +///| +/// Declarative flag constructor wrapper. +pub struct FlagArg { + priv arg : Arg + + /// Create a flag argument. + fn new( + name : StringView, + short? : Char, + long? : StringView, + about? : StringView, + action? : FlagAction, + env? : StringView, + requires? : ArrayView[String], + conflicts_with? : ArrayView[String], + required? : Bool, + global? : Bool, + negatable? : Bool, + hidden? : Bool, + ) -> FlagArg +} + +///| +/// Create a flag argument. +/// +/// Notes: +/// - `long` defaults to `name`. +/// - Use `long=""` to disable the long form. +/// - At least one of `short`, `long`, or `env` must be available. +/// - `global=true` makes the flag available in subcommands. +/// - `negatable=true` accepts `--no-` for long flags. +/// - If `env` is set, accepted boolean values are: +/// `1`, `0`, `true`, `false`, `yes`, `no`, `on`, `off`. +pub fn FlagArg::new( + name : StringView, + short? : Char, + long? : StringView = name, + about? : StringView, + action? : FlagAction = SetTrue, + env? : StringView, + requires? : ArrayView[String] = [], + conflicts_with? : ArrayView[String] = [], + required? : Bool = false, + global? : Bool = false, + negatable? : Bool = false, + hidden? : Bool = false, +) -> FlagArg { + let name = name.to_string() + let long = if long == "" { None } else { Some(long.to_string()) } + let about = about.map(v => v.to_string()) + let env = env.map(v => v.to_string()) + { + arg: { + name, + about, + env, + global, + hidden, + requires: requires.to_array(), + conflicts_with: conflicts_with.to_array(), + required, + info: FlagInfo(short~, long~, action~, negatable~), + multiple: false, + }, + } +} + +///| +/// Declarative option constructor wrapper. +/// Named `OptionArg` to avoid shadowing the built-in `Option` type. +pub struct OptionArg { + priv arg : Arg + + /// Create an option argument. + // FIXME(upstram) rename does not work here + fn new( + name : StringView, + short? : Char, + long? : StringView, + about? : StringView, + action? : OptionAction, + env? : StringView, + default_values? : ArrayView[String], + allow_hyphen_values? : Bool, + requires? : ArrayView[String], + conflicts_with? : ArrayView[String], + required? : Bool, + global? : Bool, + hidden? : Bool, + ) -> OptionArg +} + +///| +/// Create an option argument. +/// +/// Notes: +/// - `long` defaults to `name`. +/// - Use `long=""` to disable the long form. +/// - At least one of `short`, `long`, or `env` must be available. +/// - Use `action=Append` to keep repeated occurrences. +/// - `global=true` makes the option available in subcommands. +/// - `allow_hyphen_values=true` allows values like `-1` or `--raw` to be +/// consumed as this option's value when parsing argv. +pub fn OptionArg::new( + name : StringView, + short? : Char, + long? : StringView = name, + about? : StringView, + action? : OptionAction = Set, + env? : StringView, + default_values? : ArrayView[String], + allow_hyphen_values? : Bool = false, + requires? : ArrayView[String] = [], + conflicts_with? : ArrayView[String] = [], + required? : Bool = false, + global? : Bool = false, + hidden? : Bool = false, +) -> OptionArg { + let name = name.to_string() + let long = if long == "" { None } else { Some(long.to_string()) } + let about = about.map(v => v.to_string()) + let env = env.map(v => v.to_string()) + { + arg: { + name, + about, + env, + requires: requires.to_array(), + conflicts_with: conflicts_with.to_array(), + required, + global, + hidden, + info: OptionInfo( + short~, + long~, + action~, + default_values=default_values.map(values => values.to_array()), + allow_hyphen_values~, + ), + multiple: action is Append, + }, + } +} + +///| +/// Declarative positional constructor wrapper. +pub struct PositionArg { + priv arg : Arg + + /// Create a positional argument. + fn new( + name : StringView, + about? : StringView, + env? : StringView, + default_values? : ArrayView[String], + num_args? : ValueRange, + allow_hyphen_values? : Bool, + requires? : ArrayView[String], + conflicts_with? : ArrayView[String], + global? : Bool, + hidden? : Bool, + ) -> PositionArg +} + +///| +/// Create a positional argument. +/// +/// Notes: +/// - PositionArg order follows declaration order. +/// - `num_args` controls accepted value count. +/// - If `num_args` is omitted, the default is an optional single value +/// (`0..=1`). +/// - Use `num_args=ValueRange::single()` for a required single positional. +/// - `allow_hyphen_values=true` allows leading-`-` tokens to be consumed as +/// positional values (unless they match a declared option). +/// - Tokens after `--` are always treated as positional values. +pub fn PositionArg::new( + name : StringView, + about? : StringView, + env? : StringView, + default_values? : ArrayView[String], + num_args? : ValueRange, + allow_hyphen_values? : Bool = false, + requires? : ArrayView[String] = [], + conflicts_with? : ArrayView[String] = [], + global? : Bool = false, + hidden? : Bool = false, +) -> PositionArg { + let name = name.to_string() + let about = about.map(v => v.to_string()) + let env = env.map(v => v.to_string()) + { + arg: { + name, + about, + env, + requires: requires.to_array(), + conflicts_with: conflicts_with.to_array(), + required: false, + global, + hidden, + info: PositionalInfo( + num_args~, + default_values=default_values.map(values => values.to_array()), + allow_hyphen_values~, + ), + multiple: range_allows_multiple(num_args), + }, + } +} + +///| +fn arg_name(arg : Arg) -> String { + arg.name +} + +///| +fn range_allows_multiple(range : ValueRange?) -> Bool { + range is Some(r) && + (match r.upper { + Some(upper) => upper > 1 + None => true + }) +} diff --git a/argparse/argparse_blackbox_test.mbt b/argparse/argparse_blackbox_test.mbt new file mode 100644 index 000000000..27c69bf7c --- /dev/null +++ b/argparse/argparse_blackbox_test.mbt @@ -0,0 +1,3038 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "render help snapshot with groups and hidden entries" { + let cmd = @argparse.Command( + "render", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=[ + "fast", "slow", "path", + ]), + ], + subcommands=[ + Command("run", about="run"), + Command("hidden", about="hidden", hidden=true), + ], + flags=[ + FlagArg("fast", short='f', long="fast"), + FlagArg("slow", long="slow", hidden=true), + FlagArg("cache", long="cache", negatable=true, about="cache"), + ], + options=[ + OptionArg( + "path", + short='p', + long="path", + env="PATH_ENV", + default_values=["a", "b"], + required=true, + ), + ], + positionals=[ + PositionArg("target", num_args=@argparse.ValueRange::single()), + PositionArg("rest", num_args=ValueRange(lower=0)), + PositionArg("secret", hidden=true), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: render --path [options] [rest...] [command] + #| + #|Commands: + #| run run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| target + #| rest... + #| + #|Options: + #| -h, --help Show help information. + #| -f, --fast + #| --[no-]cache cache + #| -p, --path [env: PATH_ENV] [default: a, b] + #| + #|Groups: + #| mode [required] [exclusive] -f, --fast, -p, --path + #| + ), + ) +} + +///| +test "render help conversion coverage snapshot" { + let cmd = @argparse.Command( + "shape", + groups=[ArgGroup("grp", args=["f", "opt", "pos"])], + flags=[ + FlagArg( + "f", + short='f', + about="f", + env="F_ENV", + requires=["opt"], + global=true, + hidden=true, + ), + ], + options=[ + OptionArg( + "opt", + short='o', + about="opt", + default_values=["x", "y"], + env="OPT_ENV", + allow_hyphen_values=true, + required=true, + global=true, + hidden=true, + conflicts_with=["pos"], + ), + ], + positionals=[ + PositionArg( + "pos", + about="pos", + env="POS_ENV", + default_values=["p1", "p2"], + num_args=ValueRange(lower=0, upper=2), + allow_hyphen_values=true, + requires=["opt"], + conflicts_with=["f"], + global=true, + hidden=true, + ), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: shape + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "count flags and sources with pattern matching" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", short='v', long="verbose", action=Count), + ]) + let matches = cmd.parse(argv=["-v", "-v", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.flag_counts is { "verbose": 3, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) +} + +///| +test "global option merges parent and child values" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + short='p', + long="profile", + action=Append, + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse( + argv=["--profile", "parent", "run", "--profile", "child"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent", "child"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent", "child"], .. }, + ) +} + +///| +test "global requires is validated after parent-child merge" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", requires=["config"], global=true), + OptionArg("config", long="config", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse( + argv=["--config", "a.toml", "run", "--mode", "fast"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true( + parsed.values is { "config": ["a.toml"], "mode": ["fast"], .. } && + parsed.subcommand is Some(("run", sub)) && + sub.values is { "config": ["a.toml"], "mode": ["fast"], .. }, + ) +} + +///| +test "global append keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + long="profile", + action=Append, + env="PROFILE", + default_values=["def"], + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ + "PROFILE": "env", + }) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": Argv, .. }, + ) +} + +///| +test "global scalar keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + long="profile", + env="PROFILE", + default_values=["def"], + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ + "PROFILE": "env", + }) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": Argv, .. }, + ) +} + +///| +test "global count merges parent and child occurrences" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["-v", "run", "-v", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flag_counts is { "verbose": 3, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 3, .. }, + ) +} + +///| +test "global count keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + action=Count, + env="VERBOSE", + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["-v", "run"], env={ "VERBOSE": "1" }) catch { + _ => panic() + } + assert_true(matches.flag_counts is { "verbose": 1, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global flag keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", env="VERBOSE", global=true)], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--verbose", "run"], env={ "VERBOSE": "0" }) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "subcommand cannot follow positional arguments" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + try cmd.parse(argv=["raw", "run"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: subcommand 'run' cannot be used with positional arguments + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "global count source keeps env across subcommand merge" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + action=Count, + env="VERBOSE", + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["run"], env={ "VERBOSE": "1" }) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.flag_counts is { "verbose": 1, .. }) + assert_true(matches.sources is { "verbose": Env, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": Env, .. }, + ) +} + +///| +test "help subcommand styles and errors" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command("demo", subcommands=[leaf]) + + inspect( + leaf.render_help(), + content=( + #|Usage: echo + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + try cmd.parse(argv=["help", "--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected help argument: --bad + #| + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "missing"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unknown subcommand: missing + #| + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand help includes inherited global options" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + about="Enable verbose mode", + global=true, + ), + ], + subcommands=[leaf], + ) + + try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo echo [options] + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose Enable verbose mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "unknown argument suggestions are exposed" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", short='v', long="verbose"), + ]) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-x"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-x' found + #| + #| tip: a similar argument exists: '-v' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--zzzzzzzzzz"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--zzzzzzzzzz' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "long and short value parsing branches" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", short='c', long="count"), + ]) + + let long_inline = cmd.parse(argv=["--count=2"], env=empty_env()) catch { + _ => panic() + } + assert_true(long_inline.values is { "count": ["2"], .. }) + + let short_inline = cmd.parse(argv=["-c=3"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_inline.values is { "count": ["3"], .. }) + + let short_attached = cmd.parse(argv=["-c4"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_attached.values is { "count": ["4"], .. }) + + try cmd.parse(argv=["--count"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--count' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-c"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '-c' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "append option action is publicly selectable" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]) + let appended = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b"], .. }) + assert_true(appended.sources is { "tag": Argv, .. }) +} + +///| +test "negation parsing and invalid negation forms" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("cache", long="cache", negatable=true)], + options=[OptionArg("path", long="path")], + ) + + let off = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(off.flags is { "cache": false, .. }) + assert_true(off.sources is { "cache": Argv, .. }) + + try cmd.parse(argv=["--no-path"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--no-path' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--no-missing"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--no-missing' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--no-cache=1"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--no-cache=1' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + } noraise { + _ => panic() + } + + let count_cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", long="verbose", action=Count, negatable=true), + ]) + let reset = count_cmd.parse( + argv=["--verbose", "--no-verbose"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(reset.flags is { "verbose": false, .. }) + assert_true(reset.flag_counts is { "verbose"? : None, .. }) + assert_true(reset.sources is { "verbose": Argv, .. }) +} + +///| +test "positionals dash handling and separator" { + let force_cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let forced = force_cmd.parse(argv=["a", "--x", "-y"], env=empty_env()) catch { + _ => panic() + } + assert_true(forced.values is { "tail": ["a", "--x", "-y"], .. }) + + let dashed = force_cmd.parse(argv=["--", "p", "q"], env=empty_env()) catch { + _ => panic() + } + assert_true(dashed.values is { "tail": ["p", "q"], .. }) + + let negative_cmd = @argparse.Command("demo", positionals=[PositionArg("n")]) + let negative = negative_cmd.parse(argv=["-9"], env=empty_env()) catch { + _ => panic() + } + assert_true(negative.values is { "n": ["-9"], .. }) + + try negative_cmd.parse(argv=["x", "y"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'y' for '' found; no more were expected + #| + #|Usage: demo [n] + #| + #|Arguments: + #| n + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "variadic positional keeps accepting hyphen values after first token" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let parsed = cmd.parse(argv=["a", "-b", "--mystery"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tail": ["a", "-b", "--mystery"], .. }) +} + +///| +test "bounded positional does not greedily consume later required values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) + + let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) +} + +///| +test "indexed non-last positional allows explicit single num_args" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=@argparse.ValueRange::single()), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "empty positional value range is rejected at build time" { + try + @argparse.Command("demo", positionals=[ + PositionArg("skip", num_args=ValueRange(lower=0, upper=0)), + PositionArg("name", num_args=@argparse.ValueRange::single()), + ]).parse(argv=["alice"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: empty value range (0..0) is unsupported + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "env parsing for settrue setfalse count and invalid values" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("on", long="on", action=SetTrue, env="ON"), + FlagArg("off", long="off", action=SetFalse, env="OFF"), + FlagArg("v", long="v", action=Count, env="V"), + ]) + + let parsed = cmd.parse(argv=[], env={ "ON": "true", "OFF": "true", "V": "3" }) catch { + _ => panic() + } + assert_true(parsed.flags is { "on": true, "off": false, "v": true, .. }) + assert_true(parsed.flag_counts is { "v": 3, .. }) + assert_true(parsed.sources is { "on": Env, "off": Env, "v": Env, .. }) + + try cmd.parse(argv=[], env={ "ON": "bad" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "OFF": "bad" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "V": "bad" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value 'bad' for count; expected a non-negative integer + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "V": "-1" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value '-1' for count; expected a non-negative integer + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "defaults and value range helpers through public API" { + let defaults = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", action=Append, default_values=["a", "b"]), + OptionArg("one", long="one", default_values=["x"]), + ]) + let by_default = defaults.parse(argv=[], env=empty_env()) catch { + _ => panic() + } + assert_true(by_default.values is { "mode": ["a", "b"], "one": ["x"], .. }) + assert_true(by_default.sources is { "mode": Default, "one": Default, .. }) + + let upper_only = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]) + let upper_parsed = upper_only.parse( + argv=["--tag", "a", "--tag", "b", "--tag", "c"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(upper_parsed.values is { "tag": ["a", "b", "c"], .. }) + + let lower_only = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag"), + ]) + let lower_absent = lower_only.parse(argv=[], env=empty_env()) catch { + _ => panic() + } + assert_true(lower_absent.values is { "tag"? : None, .. }) + + try lower_only.parse(argv=["--tag"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } + + let single_range = @argparse.ValueRange::single() + inspect( + single_range, + content=( + #|{lower: 1, upper: Some(1)} + ), + ) +} + +///| +test "options consume exactly one value per occurrence" { + let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) + let parsed = cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["a"], .. }) + assert_true(parsed.sources is { "tag": Argv, .. }) + + try cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'b' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "set options reject duplicate occurrences" { + let cmd = @argparse.Command("demo", options=[OptionArg("mode", long="mode")]) + try cmd.parse(argv=["--mode", "a", "--mode", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "flag and option args require short or long names" { + try + @argparse.Command("demo", options=[OptionArg("input", long="")]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: flag/option args require short/long/env + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", flags=[FlagArg("verbose", long="")]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: flag/option args require short/long/env + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "append options collect values across repeated occurrences" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("arg", long="arg", action=Append), + ]) + let parsed = cmd.parse(argv=["--arg", "x", "--arg", "y"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "arg": ["x", "y"], .. }) + assert_true(parsed.sources is { "arg": Argv, .. }) +} + +///| +test "option parsing stops at the next option token" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("arg", short='a', long="arg")], + ) + + let stopped = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(stopped.values is { "arg": ["x"], .. }) + assert_true(stopped.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'y' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| -a, --arg + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'y' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| -a, --arg + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "options always require a value" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("opt", long="opt")], + ) + try cmd.parse(argv=["--opt", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--opt' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --opt + #| + ), + ) + } noraise { + _ => panic() + } + + let zero_value_required = @argparse.Command("demo", options=[ + OptionArg("opt", long="opt", required=true), + ]).parse(argv=["--opt", "x"], env=empty_env()) catch { + _ => panic() + } + assert_true(zero_value_required.values is { "opt": ["x"], .. }) +} + +///| +test "option values reject hyphen tokens unless allow_hyphen_values is enabled" { + let strict = @argparse.Command("demo", options=[ + OptionArg("pattern", long="pattern"), + ]) + let mut rejected = false + try strict.parse(argv=["--pattern", "-file"], env=empty_env()) catch { + err => { + inspect( + err, + content=( + #|error: a value is required for '--pattern' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --pattern + #| + ), + ) + rejected = true + } + } noraise { + _ => rejected = true + } + assert_true(rejected) + + let permissive = @argparse.Command("demo", options=[ + OptionArg("pattern", long="pattern", allow_hyphen_values=true), + ]) + let parsed = permissive.parse(argv=["--pattern", "-file"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "pattern": ["-file"], .. }) + assert_true(parsed.sources is { "pattern": Argv, .. }) +} + +///| + +///| +test "default argv path is reachable" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("rest", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let _ = cmd.parse(env=empty_env()) catch { _ => panic() } +} + +///| +test "validation branches exposed through parse" { + try + @argparse.Command("demo", flags=[FlagArg("f", long="", action=Help)]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: flag/option args require short/long/env + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", flags=[ + FlagArg("f", long="f", action=Help, negatable=true), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: help/version actions do not support negatable + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", flags=[ + FlagArg("f", long="f", action=Help, env="F"), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: help/version actions do not support env/defaults + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", options=[OptionArg("x", long="x")]).parse( + argv=["--x", "a", "b"], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'b' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --x + #| + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", options=[ + OptionArg("x", long="x", default_values=["a", "b"]), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: default_values with multiple entries require action=Append + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=3, upper=2)), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: max values must be >= min values + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=-1, upper=2)), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: min values must be >= 0 + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=-1)), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: max values must be >= 0 + ), + ) + } noraise { + _ => panic() + } + + let positional_ok = @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=2)), + PositionArg("y"), + ]).parse(argv=["a"], env=empty_env()) catch { + _ => panic() + } + assert_true(positional_ok.values is { "x": ["a"], "y"? : None, .. }) + + try + @argparse.Command("demo", groups=[ArgGroup("g"), ArgGroup("g")]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: duplicate group: g + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[ArgGroup("g", requires=["g"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: group cannot require itself: g + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[ArgGroup("g", conflicts_with=["g"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: group cannot conflict with itself: g + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[ArgGroup("g", args=["missing"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: unknown group arg: g -> missing + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", options=[ + OptionArg("x", long="x"), + OptionArg("x", long="y"), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: duplicate arg name: x + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", options=[ + OptionArg("x", long="same"), + OptionArg("y", long="same"), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: duplicate long option: --same + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", flags=[ + FlagArg("hello", long="hello", negatable=true), + FlagArg("x", long="no-hello"), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: duplicate long option: --no-hello + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", options=[ + OptionArg("x", short='s'), + OptionArg("y", short='s'), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: duplicate short option: -s + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", flags=[FlagArg("x", long="x", requires=["x"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: arg cannot require itself: x + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", flags=[ + FlagArg("x", long="x", conflicts_with=["x"]), + ]).parse(argv=[], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: arg cannot conflict with itself: x + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", subcommands=[Command("x"), Command("x")]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: duplicate subcommand: x + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", subcommand_required=true).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: subcommand_required requires at least one subcommand + ), + ) + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", subcommands=[Command("help")]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: subcommand name reserved for built-in help: help (disable with disable_help_subcommand) + ), + ) + } noraise { + _ => panic() + } + + let custom_help = @argparse.Command("demo", flags=[ + FlagArg("custom_help", short='h', long="help", about="custom help"), + ]) + let help_short = custom_help.parse(argv=["-h"], env=empty_env()) catch { + _ => panic() + } + let help_long = custom_help.parse(argv=["--help"], env=empty_env()) catch { + _ => panic() + } + assert_true(help_short.flags is { "custom_help": true, .. }) + assert_true(help_long.flags is { "custom_help": true, .. }) + inspect( + custom_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help custom help + #| + ), + ) + + let custom_version = @argparse.Command("demo", version="1.0", flags=[ + FlagArg("custom_version", short='V', long="version", about="custom version"), + ]) + let version_short = custom_version.parse(argv=["-V"], env=empty_env()) catch { + _ => panic() + } + let version_long = custom_version.parse(argv=["--version"], env=empty_env()) catch { + _ => panic() + } + assert_true(version_short.flags is { "custom_version": true, .. }) + assert_true(version_long.flags is { "custom_version": true, .. }) + inspect( + custom_version.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version custom version + #| + ), + ) + + try + @argparse.Command("demo", flags=[FlagArg("v", long="v", action=Version)]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: version action requires command version text + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "builtin and custom help/version dispatch edge paths" { + let versioned = @argparse.Command("demo", version="1.2.3") + inspect( + versioned.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + try versioned.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } + + let long_help = @argparse.Command("demo", flags=[ + FlagArg("assist", long="assist", action=Help), + ]) + inspect( + long_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --assist + #| + ), + ) + + let short_help = @argparse.Command("demo", flags=[ + FlagArg("assist", short='?', action=Help), + ]) + inspect( + short_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -?, --assist + #| + ), + ) +} + +///| +test "subcommand lookup falls back to positional value" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + let parsed = cmd.parse(argv=["raw"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.values is { "input": ["raw"], .. }) + assert_true(parsed.subcommand is None) +} + +///| +test "group validation catches unknown requires target" { + try + @argparse.Command("demo", groups=[ArgGroup("g", requires=["missing"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: unknown group requires target: g -> missing + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "group validation catches unknown conflicts_with target" { + try + @argparse.Command("demo", groups=[ArgGroup("g", conflicts_with=["missing"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: unknown group conflicts_with target: g -> missing + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "group requires/conflicts can target argument names" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", args=["fast"], requires=["config"])], + flags=[FlagArg("fast", long="fast")], + options=[OptionArg("config", long="config")], + ) + + let ok = requires_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(ok.flags is { "fast": true, .. }) + assert_true(ok.values is { "config": ["cfg.toml"], .. }) + + try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'config' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --config + #| + #|Groups: + #| mode --fast + #| + ), + ) + } noraise { + _ => panic() + } + + let conflicts_cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", args=["fast"], conflicts_with=["config"])], + flags=[FlagArg("fast", long="fast")], + options=[OptionArg("config", long="config")], + ) + + try + conflicts_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: group conflict mode conflicts with config + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --config + #| + #|Groups: + #| mode --fast + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "group without members has no parse effect" { + let cmd = @argparse.Command("demo", groups=[ArgGroup("known")], flags=[ + FlagArg("x", long="x"), + ]) + let parsed = cmd.parse(argv=["--x"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.flags is { "x": true, .. }) + let help = cmd.render_help() + assert_true(help.has_prefix("Usage: demo [options]")) +} + +///| +test "arg validation catches unknown requires target" { + try + @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", requires=["missing"]), + ]).parse(argv=["--mode", "fast"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: unknown requires target: mode -> missing + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "arg validation catches unknown conflicts_with target" { + try + @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", conflicts_with=["missing"]), + ]).parse(argv=["--mode", "fast"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: unknown conflicts_with target: mode -> missing + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "empty groups without presence do not fail" { + let grouped_ok = @argparse.Command( + "demo", + groups=[ArgGroup("left", args=["l"]), ArgGroup("right", args=["r"])], + flags=[FlagArg("l", long="left"), FlagArg("r", long="right")], + ) + let parsed = grouped_ok.parse(argv=["--left"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "l": true, .. }) +} + +///| +test "help rendering edge paths stay stable" { + let required_many = @argparse.Command("demo", positionals=[ + PositionArg("files", num_args=ValueRange(lower=1)), + ]) + let required_help = required_many.render_help() + assert_true(required_help.has_prefix("Usage: demo ")) + + let short_only_builtin = @argparse.Command("demo", options=[ + OptionArg("helpopt", long="help"), + ]) + let short_only_text = short_only_builtin.render_help() + assert_true(short_only_text.has_prefix("Usage: demo")) + try short_only_builtin.parse(argv=["--help"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--help' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h Show help information. + #| --help + #| + ), + ) + } noraise { + _ => panic() + } + + let long_only_builtin = @argparse.Command("demo", flags=[ + FlagArg("custom_h", short='h'), + ]) + let long_only_text = long_only_builtin.render_help() + assert_true(long_only_text.has_prefix("Usage: demo")) + let custom_h = long_only_builtin.parse(argv=["-h"], env=empty_env()) catch { + _ => panic() + } + assert_true(custom_h.flags is { "custom_h": true, .. }) + + let empty_options = @argparse.Command( + "demo", + disable_help_flag=true, + disable_version_flag=true, + ) + let empty_options_help = empty_options.render_help() + assert_true(empty_options_help.has_prefix("Usage: demo")) + + let implicit_group = @argparse.Command("demo", positionals=[ + PositionArg("item"), + ]) + let implicit_group_help = implicit_group.render_help() + assert_true(implicit_group_help.has_prefix("Usage: demo [item]")) + + let sub_visible = @argparse.Command("demo", disable_help_subcommand=true, subcommands=[ + Command("run"), + ]) + let sub_help = sub_visible.render_help() + assert_true(sub_help.has_prefix("Usage: demo [command]")) +} + +///| +test "unified error message formatting remains stable" { + let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--tag"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "options require one value per occurrence" { + let with_value = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag"), + ]).parse(argv=["--tag", "x"], env=empty_env()) catch { + _ => panic() + } + assert_true(with_value.values is { "tag": ["x"], .. }) + + try + @argparse.Command("demo", options=[OptionArg("tag", long="tag")]).parse( + argv=["--tag"], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "short options require one value before next option token" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", short='v')], options=[ + OptionArg("x", short='x'), + ]) + let ok = cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "x": ["a"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["-x", "-v"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '-x' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| -x, --x + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "version action dispatches on custom long and short flags" { + let cmd = @argparse.Command("demo", version="2.0.0", flags=[ + FlagArg("show_long", long="show-version", action=Version), + FlagArg("show_short", short='S', action=Version), + ]) + + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| --show-version + #| -S, --show_short + #| + ), + ) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| --show-version + #| -S, --show_short + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "global version action keeps parent version text in subcommand context" { + let cmd = @argparse.Command( + "demo", + version="1.0.0", + flags=[ + FlagArg( + "show_version", + short='S', + long="show-version", + action=Version, + global=true, + ), + ], + subcommands=[Command("run")], + ) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| -S, --show-version + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run [options] + #| + #|Options: + #| -h, --help Show help information. + #| -S, --show-version + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand help puts required options in usage" { + let cmd = @argparse.Command("demo", subcommands=[ + Command( + "run", + about="Run a file", + options=[OptionArg("mode", short='m', required=true)], + positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())], + ), + ]) + + try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run --mode + #| + #|Run a file + #| + #|Arguments: + #| file + #| + #|Options: + #| -h, --help Show help information. + #| -m, --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "required and env-fed ranged values validate after parsing" { + let required_cmd = @argparse.Command("demo", options=[ + OptionArg("input", long="input", required=true), + ]) + try required_cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'input' + #| + #|Usage: demo --input + #| + #|Options: + #| -h, --help Show help information. + #| --input + #| + ), + ) + } noraise { + _ => panic() + } + + let env_min_cmd = @argparse.Command("demo", options=[ + OptionArg("pair", long="pair", env="PAIR"), + ]) + let env_value = env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { + _ => panic() + } + assert_true(env_value.values is { "pair": ["one"], .. }) + assert_true(env_value.sources is { "pair": Env, .. }) +} + +///| +test "positionals keep declaration order with ranged positional" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("late", num_args=ValueRange(lower=2, upper=2)), + PositionArg("first"), + PositionArg("mid"), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c", "d"], env=empty_env()) catch { + _ => panic() + } + assert_true( + parsed.values is { "late": ["a", "b"], "first": ["c"], "mid": ["d"], .. }, + ) +} + +///| +test "mixed indexed and unindexed positionals keep inferred order" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first"), + PositionArg("second"), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "single positional parses without explicit index metadata" { + let parsed = @argparse.Command("demo", positionals=[PositionArg("late")]).parse( + argv=["x"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.values is { "late": ["x"], .. }) +} + +///| +test "positional num_args lower bound rejects missing argv values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=2, upper=3)), + ]) + + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: 'first' requires at least 2 values but only 0 were provided + #| + #|Usage: demo + #| + #|Arguments: + #| first... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "positional max clamp leaves trailing value for next positional" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("items", num_args=ValueRange(lower=0, upper=2)), + PositionArg("tail"), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "items": ["a", "b"], "tail": ["c"], .. }) +} + +///| +test "options with allow_hyphen_values accept option-like single values" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg("verbose", long="verbose"), + FlagArg("cache", long="cache", negatable=true), + FlagArg("quiet", short='q'), + ], + options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], + ) + + let known_long = cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_long.values is { "arg": ["--verbose"], .. }) + assert_true(known_long.flags is { "verbose"? : None, .. }) + + let negated = cmd.parse(argv=["--arg", "--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(negated.values is { "arg": ["--no-cache"], .. }) + assert_true(negated.flags is { "cache"? : None, .. }) + + let unknown_long_value = cmd.parse( + argv=["--arg", "--mystery"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(unknown_long_value.values is { "arg": ["--mystery"], .. }) + + let known_short = cmd.parse(argv=["--arg", "-q"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_short.values is { "arg": ["-q"], .. }) + assert_true(known_short.flags is { "quiet"? : None, .. }) + + let cmd_with_rest = @argparse.Command( + "demo", + options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], + positionals=[ + PositionArg( + "rest", + num_args=ValueRange(lower=0), + allow_hyphen_values=true, + ), + ], + ) + let sentinel_stop = cmd_with_rest.parse( + argv=["--arg", "x", "--", "tail"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(sentinel_stop.values is { "arg": ["x"], "rest": ["tail"], .. }) +} + +///| +test "single-value options avoid consuming additional option values" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("one", long="one")], + ) + + let parsed = cmd.parse(argv=["--one", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "one": ["x"], .. }) + assert_true(parsed.flags is { "verbose": true, .. }) +} + +///| +test "missing option values are reported when next token is another option" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("arg", long="arg")], + ) + + let ok = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "arg": ["x"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--arg' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --arg + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "short-only set options use short label in duplicate errors" { + let cmd = @argparse.Command("demo", options=[OptionArg("mode", short='m')]) + try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -m, --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "unknown short suggestion can be absent" { + let cmd = @argparse.Command("demo", disable_help_flag=true, options=[ + OptionArg("name", long="name"), + ]) + + try cmd.parse(argv=["-x"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-x' found + #| + #|Usage: demo [options] + #| + #|Options: + #| --name + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "setfalse flags apply false when present" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("failfast", long="failfast", action=SetFalse), + ]) + let parsed = cmd.parse(argv=["--failfast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "failfast": false, .. }) + assert_true(parsed.sources is { "failfast": Argv, .. }) +} + +///| +test "allow_hyphen positional treats unknown long token as value" { + let cmd = @argparse.Command("demo", flags=[FlagArg("known", long="known")], positionals=[ + PositionArg("input", allow_hyphen_values=true), + ]) + let parsed = cmd.parse(argv=["--mystery"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "input": ["--mystery"], .. }) +} + +///| +test "global value from child default is merged back to parent" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", default_values=["safe"], global=true), + OptionArg("unused", long="unused", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.values is { "mode": ["safe"], "unused"? : None, .. }) + assert_true(parsed.sources is { "mode": Default, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["safe"], .. } && + sub.sources is { "mode": Default, .. }, + ) +} + +///| +test "env-only global is propagated to nested subcommand matches" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("level", long="", env="LEVEL", global=true)], + subcommands=[Command("run", subcommands=[Command("leaf")])], + ) + + let parsed = cmd.parse(argv=["run", "leaf"], env={ "LEVEL": "5" }) catch { + _ => panic() + } + assert_true(parsed.values is { "level": ["5"], .. }) + assert_true(parsed.sources is { "level": Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub_run)) && + sub_run.values is { "level": ["5"], .. } && + sub_run.sources is { "level": Env, .. } && + sub_run.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.values is { "level": ["5"], .. } && + sub_leaf.sources is { "level": Env, .. }, + ) +} + +///| +test "child global arg with inherited global name updates parent global" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", default_values=["safe"], global=true), + ], + subcommands=[ + Command("run", options=[OptionArg("mode", long="mode", global=true)]), + ], + ) + + let parsed = cmd.parse(argv=["run", "--mode", "fast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.sources is { "mode": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Argv, .. }, + ) +} + +///| +test "child global override env/default win over inherited definition" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "mode", + long="mode", + env="ROOT_MODE", + default_values=["safe"], + global=true, + ), + ], + subcommands=[ + Command("run", options=[ + OptionArg( + "mode", + long="mode", + env="RUN_MODE", + default_values=["fast"], + global=true, + ), + ]), + ], + ) + + let from_env = cmd.parse(argv=["run"], env={ + "ROOT_MODE": "root-env", + "RUN_MODE": "run-env", + }) catch { + _ => panic() + } + assert_true(from_env.values is { "mode": ["run-env"], .. }) + assert_true(from_env.sources is { "mode": Env, .. }) + assert_true( + from_env.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["run-env"], .. } && + sub.sources is { "mode": Env, .. }, + ) + + let from_default = cmd.parse(argv=["run"], env={}) catch { _ => panic() } + assert_true(from_default.values is { "mode": ["fast"], .. }) + assert_true(from_default.sources is { "mode": Default, .. }) + assert_true( + from_default.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Default, .. }, + ) +} + +///| +test "inherited argv global satisfies child required global override" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[ + Command("run", options=[ + OptionArg("mode", long="mode", required=true, global=true), + ]), + ], + ) + + let parsed = cmd.parse(argv=["--mode", "fast", "run"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.sources is { "mode": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Argv, .. }, + ) +} + +///| +test "child local arg shadowing inherited global is rejected at build time" { + try + @argparse.Command( + "demo", + options=[ + OptionArg( + "mode", + long="mode", + env="MODE", + default_values=["safe"], + global=true, + ), + ], + subcommands=[Command("run", options=[OptionArg("mode", long="mode")])], + ).parse(argv=["run"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: arg 'mode' shadows an inherited global; rename the arg or mark it global + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "global append env value from child is merged back to parent" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("tag", long="tag", action=Append, env="TAG", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run"], env={ "TAG": "env-tag" }) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["env-tag"], .. }) + assert_true(parsed.sources is { "tag": Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "tag": ["env-tag"], .. } && + sub.sources is { "tag": Env, .. }, + ) +} + +///| +test "global flag set in child argv is merged back to parent" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": true, .. }) + assert_true(parsed.sources is { "verbose": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global count negation after subcommand resets merged state" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + long="verbose", + action=Count, + negatable=true, + global=true, + ), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse( + argv=["--verbose", "run", "--no-verbose"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": false, .. }) + assert_true(parsed.flag_counts.get("verbose") is None) + assert_true(parsed.sources is { "verbose": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": false, .. } && + sub.flag_counts.get("verbose") is None && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global set option rejects duplicate occurrences across subcommands" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[Command("run")], + ) + try + cmd.parse(argv=["--mode", "a", "run", "--mode", "b"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "global override with incompatible inherited type is rejected" { + try + @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", required=true, global=true)], + subcommands=[ + Command("run", flags=[FlagArg("mode", long="mode", global=true)]), + ], + ).parse(argv=["run", "--mode"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: global arg 'mode' is incompatible with inherited global definition + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "child local long alias collision with inherited global is rejected" { + try + @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[Command("run", options=[OptionArg("local", long="verbose")])], + ).parse(argv=["run", "--verbose"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: arg 'local' long option --verbose conflicts with inherited global 'verbose' + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "child local short alias collision with inherited global is rejected" { + try + @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', global=true)], + subcommands=[Command("run", options=[OptionArg("local", short='v')])], + ).parse(argv=["run", "-v"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: arg 'local' short option -v conflicts with inherited global 'verbose' + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "nested subcommands inherit finalized globals from ancestors" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command("mid", subcommands=[leaf]) + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[mid], + ) + + let parsed = cmd.parse(argv=["--verbose", "mid", "leaf"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": true, .. }) + assert_true( + parsed.subcommand is Some(("mid", mid_matches)) && + mid_matches.flags is { "verbose": true, .. } && + mid_matches.subcommand is Some(("leaf", leaf_matches)) && + leaf_matches.flags is { "verbose": true, .. } && + leaf_matches.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "non-bmp short option token does not panic" { + let cmd = @argparse.Command("demo", flags=[FlagArg("party", short='🎉')]) + let parsed = cmd.parse(argv=["-🎉"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.flags is { "party": true, .. }) +} + +///| +test "non-bmp hyphen token reports unknown argument without panic" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("value")]) + try cmd.parse(argv=["-🎉"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-🎉' found + #| + #|Usage: demo [value] + #| + #|Arguments: + #| value + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "option env values remain string values instead of flags" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", env="MODE"), + ]) + let parsed = cmd.parse(argv=[], env={ "MODE": "fast" }) catch { _ => panic() } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.flags.get("mode") is None) + assert_true(parsed.sources is { "mode": Env, .. }) +} + +///| +test "nested global override deduplicates count merge by name" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command( + "mid", + flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], + subcommands=[leaf], + ) + let root = @argparse.Command( + "root", + flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], + subcommands=[mid], + ) + + let parsed = root.parse(argv=["mid", "leaf", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flag_counts is { "verbose": 1, .. }) + assert_true( + parsed.subcommand is Some(("mid", sub_mid)) && + sub_mid.flag_counts is { "verbose": 1, .. } && + sub_mid.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.flag_counts is { "verbose": 1, .. }, + ) +} + +///| +test "nested global override keeps single set value without false duplicate error" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command( + "mid", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[leaf], + ) + let root = @argparse.Command( + "root", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[mid], + ) + + let parsed = root.parse( + argv=["mid", "leaf", "--mode", "fast"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true( + parsed.subcommand is Some(("mid", sub_mid)) && + sub_mid.values is { "mode": ["fast"], .. } && + sub_mid.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.values is { "mode": ["fast"], .. }, + ) +} + +///| +test "global override with different negatable setting is rejected" { + try + @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", negatable=true, global=true)], + subcommands=[ + Command("run", flags=[ + FlagArg("verbose", long="verbose", negatable=false, global=true), + ]), + ], + ).parse(argv=["run"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: global arg 'verbose' is incompatible with inherited global definition + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "positional range 0..1 renders as single optional value" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=1)), + ]) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [x] + #| + #|Arguments: + #| x + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} diff --git a/argparse/argparse_test.mbt b/argparse/argparse_test.mbt new file mode 100644 index 000000000..add337644 --- /dev/null +++ b/argparse/argparse_test.mbt @@ -0,0 +1,797 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn empty_env() -> Map[String, String] { + {} +} + +///| +test "declarative parse basics" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("count", long="count", env="COUNT")], + positionals=[PositionArg("name")], + ) + let matches = cmd.parse(argv=["-v", "--count", "3", "alice"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], "name": ["alice"], .. }) + assert_true( + matches.sources is { "verbose": Argv, "count": Argv, "name": Argv, .. }, + ) +} + +///| +test "long defaults to name when omitted" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose")], options=[ + OptionArg("count"), + ]) + let matches = cmd.parse(argv=["--verbose", "--count", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], .. }) +} + +///| +test "long empty string disables long alias" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="")], + options=[OptionArg("count", short='c', long="")], + ) + + let matches = cmd.parse(argv=["-v", "-c", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], .. }) + + try cmd.parse(argv=["--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbose' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v + #| -c + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--count", "3"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--count' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v + #| -c + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "declaration order controls positional parsing" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first"), + PositionArg("second"), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "bounded non-last positional remains supported" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) + + let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) +} + +///| +test "negatable flag preserves false state" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("cache", long="cache", negatable=true), + ]) + + let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_cache.flags is { "cache": false, .. }) + assert_true(no_cache.flag_counts.get("cache") is None) +} + +///| +test "parse failure message contains error and contextual help" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", long="count", about="repeat count"), + ]) + + try cmd.parse(argv=["--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --count repeat count + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand parse errors include subcommand help" { + let cmd = @argparse.Command("demo", subcommands=[ + Command("echo", options=[OptionArg("times", long="times")]), + ]) + + try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo echo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --times + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "build errors are surfaced as validation failure message" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("fast", long="fast", requires=["missing"]), + ]) + + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: command definition validation failed: unknown requires target: fast -> missing + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "unknown argument keeps suggestion in final message" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "render_help remains available for pure formatting" { + let cmd = @argparse.Command( + "demo", + about="Demo command", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("count", long="count")], + positionals=[PositionArg("name")], + subcommands=[Command("echo")], + ) + + let help = cmd.render_help() + assert_true(help.contains("Usage: demo [options] [name] [command]")) + assert_true(help.contains("Commands:")) + assert_true(help.contains("Options:")) +} + +///| +test "display help and version" { + let cmd = @argparse.Command("demo", about="demo app", version="1.2.3") + + inspect( + cmd.render_help(), + content=( + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "parse error show is readable" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + positionals=[PositionArg("name")], + ) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] [name] + #| + #|Arguments: + #| name + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["alice", "bob"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'bob' for '' found; no more were expected + #| + #|Usage: demo [options] [name] + #| + #|Arguments: + #| name + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "relationships and num args" { + let requires_cmd = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", requires=["config"]), + OptionArg("config", long="config"), + ]) + + try requires_cmd.parse(argv=["--mode", "fast"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'config' (required by 'mode') + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| --config + #| + ), + ) + } noraise { + _ => panic() + } + + let appended = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]).parse(argv=["--tag", "a", "--tag", "b", "--tag", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) +} + +///| +test "arg groups required and multiple" { + let cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=["fast", "slow"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("slow", long="slow")], + ) + + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required arguments were not provided: + #| <--fast|--slow> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--fast", "--slow"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: group conflict mode + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "arg groups requires and conflicts" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", args=["fast"], requires=["output"]), + ArgGroup("output", args=["json"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], + ) + + try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required arguments were not provided: + #| <--json> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --json + #| + #|Groups: + #| mode --fast + #| output --json + #| + ), + ) + } noraise { + _ => panic() + } + + let conflict_cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", args=["fast"], conflicts_with=["output"]), + ArgGroup("output", args=["json"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], + ) + + try conflict_cmd.parse(argv=["--fast", "--json"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: group conflict mode conflicts with output + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --json + #| + #|Groups: + #| mode --fast + #| output --json + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand parsing" { + let echo = @argparse.Command("echo", positionals=[PositionArg("msg")]) + let root = @argparse.Command("root", subcommands=[echo]) + + let matches = root.parse(argv=["echo", "hi"], env=empty_env()) catch { + _ => panic() + } + assert_true( + matches.subcommand is Some(("echo", sub)) && + sub.values is { "msg": ["hi"], .. }, + ) +} + +///| +test "full help snapshot" { + let cmd = @argparse.Command( + "demo", + about="Demo command", + flags=[ + FlagArg("verbose", short='v', long="verbose", about="Enable verbose mode"), + ], + options=[ + OptionArg("count", long="count", about="Repeat count", default_values=[ + "1", + ]), + ], + positionals=[PositionArg("name", about="Target name")], + subcommands=[Command("echo", about="Echo a message")], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] [name] [command] + #| + #|Demo command + #| + #|Commands: + #| echo Echo a message + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| name Target name + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose Enable verbose mode + #| --count Repeat count [default: 1] + #| + ), + ) +} + +///| +test "value source precedence argv env default" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("level", long="level", env="LEVEL", default_values=["1"]), + ]) + + let from_default = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } + assert_true(from_default.values is { "level": ["1"], .. }) + assert_true(from_default.sources is { "level": Default, .. }) + + let from_env = cmd.parse(argv=[], env={ "LEVEL": "2" }) catch { _ => panic() } + assert_true(from_env.values is { "level": ["2"], .. }) + assert_true(from_env.sources is { "level": Env, .. }) + + let from_argv = cmd.parse(argv=["--level", "3"], env={ "LEVEL": "2" }) catch { + _ => panic() + } + assert_true(from_argv.values is { "level": ["3"], .. }) + assert_true(from_argv.sources is { "level": Argv, .. }) +} + +///| +test "omitted env does not read process environment by default" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", long="count", env="COUNT"), + ]) + let matches = cmd.parse(argv=[]) catch { _ => panic() } + assert_true(matches.values is { "count"? : None, .. }) + assert_true(matches.sources is { "count"? : None, .. }) +} + +///| +test "options and multiple values" { + let serve = @argparse.Command("serve") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("count", short='c', long="count"), + OptionArg("tag", long="tag", action=Append), + ], + subcommands=[serve], + ) + + let long_count = cmd.parse(argv=["--count", "2"], env=empty_env()) catch { + _ => panic() + } + assert_true(long_count.values is { "count": ["2"], .. }) + + let short_count = cmd.parse(argv=["-c", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_count.values is { "count": ["3"], .. }) + + let multi = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(multi.values is { "tag": ["a", "b"], .. }) + + let subcommand = cmd.parse(argv=["serve"], env=empty_env()) catch { + _ => panic() + } + assert_true(subcommand.subcommand is Some(("serve", _))) +} + +///| +test "negatable and conflicts" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("cache", long="cache", negatable=true), + FlagArg("failfast", long="failfast", action=SetFalse, negatable=true), + FlagArg("verbose", long="verbose", conflicts_with=["quiet"]), + FlagArg("quiet", long="quiet"), + ]) + + let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_cache.flags is { "cache": false, .. }) + assert_true(no_cache.sources is { "cache": Argv, .. }) + + let no_failfast = cmd.parse(argv=["--no-failfast"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_failfast.flags is { "failfast": true, .. }) + + try cmd.parse(argv=["--verbose", "--quiet"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: conflicting arguments: verbose and quiet + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --[no-]failfast + #| --verbose + #| --quiet + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "flag does not accept inline value" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + try cmd.parse(argv=["--verbose=true"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbose=true' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "built-in long flags do not accept inline value" { + let cmd = @argparse.Command("demo", version="1.2.3") + + try cmd.parse(argv=["--help=1"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--help=1' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--version=1"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--version=1' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "command policies" { + let help_cmd = @argparse.Command("demo", arg_required_else_help=true) + inspect( + help_cmd.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + let sub_cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ + Command("echo"), + ]) + inspect( + sub_cmd.render_help(), + content=( + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + try sub_cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'subcommand' + #| + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} diff --git a/argparse/command.mbt b/argparse/command.mbt new file mode 100644 index 000000000..93a4ebdc1 --- /dev/null +++ b/argparse/command.mbt @@ -0,0 +1,269 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Declarative command specification. +pub struct Command { + priv name : String + priv args : Array[Arg] + priv groups : Array[ArgGroup] + priv subcommands : Array[Command] + priv about : String? + priv version : String? + priv disable_help_flag : Bool + priv disable_version_flag : Bool + priv disable_help_subcommand : Bool + priv arg_required_else_help : Bool + priv subcommand_required : Bool + priv hidden : Bool + priv mut build_error : ArgBuildError? + + /// Create a declarative command specification. + fn new( + name : StringView, + flags? : ArrayView[FlagArg], + options? : ArrayView[OptionArg], + positionals? : ArrayView[PositionArg], + subcommands? : ArrayView[Command], + about? : StringView, + version? : StringView, + disable_help_flag? : Bool, + disable_version_flag? : Bool, + disable_help_subcommand? : Bool, + arg_required_else_help? : Bool, + subcommand_required? : Bool, + hidden? : Bool, + groups? : ArrayView[ArgGroup], + ) -> Command +} + +///| +/// Create a declarative command specification. +/// +/// Notes: +/// - `flags`, `options`, and `positionals` declare arguments by kind. +/// - `groups` declares argument-group membership and policies. +/// - `disable_help_flag` / `disable_version_flag` disable built-in +/// `--help` / `--version`. +/// - `disable_help_subcommand` disables built-in `help ` routing. +/// - `arg_required_else_help=true` prints help when no argv tokens are provided. +/// - `subcommand_required=true` requires selecting a subcommand. +/// - `hidden=true` omits this command from parent command listings. +pub fn Command::new( + name : StringView, + flags? : ArrayView[FlagArg] = [], + options? : ArrayView[OptionArg] = [], + positionals? : ArrayView[PositionArg] = [], + subcommands? : ArrayView[Command] = [], + about? : StringView, + version? : StringView, + disable_help_flag? : Bool = false, + disable_version_flag? : Bool = false, + disable_help_subcommand? : Bool = false, + arg_required_else_help? : Bool = false, + subcommand_required? : Bool = false, + hidden? : Bool = false, + groups? : ArrayView[ArgGroup] = [], +) -> Command { + let (parsed_args, arg_error) = collect_args(flags, options, positionals) + let groups = groups.to_array() + let cmd = Command::{ + name: name.to_string(), + args: parsed_args, + groups, + subcommands: subcommands.to_array(), + about: about.map(v => v.to_string()), + version: version.map(v => v.to_string()), + disable_help_flag, + disable_version_flag, + disable_help_subcommand, + arg_required_else_help, + subcommand_required, + hidden, + build_error: arg_error, + } + if cmd.build_error is None { + validate_command(cmd, parsed_args, groups, []) catch { + err => cmd.build_error = Some(err) + } + } + cmd +} + +///| +/// Render help text without parsing. +pub fn Command::render_help(self : Command) -> String { + render_help(self) +} + +///| +/// Parse argv/environment according to this command spec. +/// +/// Behavior: +/// - Help/version requests print output immediately and terminate with exit code +/// `0`. +/// - Parse failures raise display-ready error text with full contextual help. +/// - Command-definition validation failures raise display-ready validation +/// text (without appended help). +/// +/// Value precedence is `argv > env > default_values`. +#as_free_fn +pub fn Command::parse( + self : Command, + argv? : ArrayView[String] = default_argv(), + env? : Map[String, String] = {}, +) -> Matches raise { + try { + let raw = parse_command(self, argv, env, [], {}, {}, self.name) + build_matches(self, raw, []) + } catch { + DisplayHelp::Message(text) => print_and_exit_success(text) + DisplayVersion::Message(text) => print_and_exit_success(text) + ArgError::Message(_) as err => raise err + err => { + println(err.to_string()) + panic() + } + } +} + +///| +fn build_matches( + cmd : Command, + raw : Matches, + inherited_globals : Array[Arg], +) -> Matches { + let flags : Map[String, Bool] = {} + let values : Map[String, Array[String]] = {} + let flag_counts : Map[String, Int] = {} + let sources : Map[String, ValueSource] = {} + let specs = inherited_globals + cmd.args + + for spec in specs { + let name = arg_name(spec) + if raw.values.get(name) is Some(vs) { + values[name] = vs.copy() + } + let count = raw.counts.get_or_default(name, 0) + if count > 0 { + flag_counts[name] = count + } + let source = match raw.flag_sources.get(name) { + Some(v) => Some(v) + None => raw.value_sources.get(name) + } + if source is Some(source) { + sources[name] = source + if spec.info is FlagInfo(action~, ..) { + if action is Count { + flags[name] = count > 0 + } else { + flags[name] = raw.flags.get(name).unwrap_or(false) + } + } + } + } + let child_globals = merge_global_defs( + inherited_globals, + collect_globals(cmd.args), + ) + + let subcommand = match raw.parsed_subcommand { + Some((name, sub_raw)) => + if find_decl_subcommand(cmd.subcommands, name) is Some(sub_spec) { + Some((name, build_matches(sub_spec, sub_raw, child_globals))) + } else { + Some( + ( + name, + { + flags: {}, + values: {}, + flag_counts: {}, + sources: {}, + subcommand: None, + counts: {}, + flag_sources: {}, + value_sources: {}, + parsed_subcommand: None, + }, + ), + ) + } + None => None + } + + { + flags, + values, + flag_counts, + sources, + subcommand, + counts: {}, + flag_sources: {}, + value_sources: {}, + parsed_subcommand: None, + } +} + +///| +fn find_decl_subcommand(subs : Array[Command], name : String) -> Command? { + for sub in subs { + if sub.name == name { + return Some(sub) + } + } + None +} + +///| +fn collect_args( + flags : ArrayView[FlagArg], + options : ArrayView[OptionArg], + positionals : ArrayView[PositionArg], +) -> (Array[Arg], ArgBuildError?) { + let args : Array[Arg] = [] + for flag in flags { + args.push(flag.arg) + } + for option in options { + args.push(option.arg) + } + for positional in positionals { + args.push(positional.arg) + } + let ctx = ValidationCtx::new() + let mut first_error : ArgBuildError? = None + for flag in flags { + validate_flag_arg(flag.arg, ctx) catch { + err => if first_error is None { first_error = Some(err) } + } + } + for option in options { + validate_option_arg(option.arg, ctx) catch { + err => if first_error is None { first_error = Some(err) } + } + } + for positional in positionals { + validate_positional_arg(positional.arg, ctx) catch { + err => if first_error is None { first_error = Some(err) } + } + } + if first_error is None { + ctx.finalize() catch { + err => first_error = Some(err) + } + } + (args, first_error) +} diff --git a/argparse/error.mbt b/argparse/error.mbt new file mode 100644 index 000000000..386eaddc5 --- /dev/null +++ b/argparse/error.mbt @@ -0,0 +1,102 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Internal error surface used by argparse. +/// +/// `Message` is display-ready text intended for end users. +/// Parse failures include contextual help; command-definition validation +/// failures are returned as plain validation messages. +priv suberror ArgError { + Message(String) +} + +///| +impl Show for ArgError with output(self : ArgError, logger) { + match self { + Message(msg) => logger.write_string(msg) + } +} + +///| +/// Internal parse error variants used while parsing. +priv suberror ArgParseError { + UnknownArgument(String, String?) + InvalidArgument(String) + MissingValue(String) + MissingRequired(String, String?) + TooFewValues(String, Int, Int) + TooManyValues(String, Int, Int) + TooManyPositionals(String, String?) + InvalidValue(String) + MissingGroup(String) + GroupConflict(String) +} + +///| +fn ArgParseError::arg_parse_error_message(self : ArgParseError) -> String { + match self { + UnknownArgument(arg, Some(hint)) => + ( + $|error: unexpected argument '\{arg}' found + $| + $| tip: a similar argument exists: '\{hint}' + ) + UnknownArgument(arg, None) => "error: unexpected argument '\{arg}' found" + InvalidArgument(arg) => + if arg.has_prefix("-") { + "error: unexpected argument '\{arg}' found" + } else { + "error: \{arg}" + } + MissingValue(arg) => + "error: a value is required for '\{arg}' but none was supplied" + MissingRequired(name, by) => + if by is Some(source) { + "error: the following required argument was not provided: '\{name}' (required by '\{source}')" + } else { + "error: the following required argument was not provided: '\{name}'" + } + TooFewValues(name, got, min) => + "error: '\{name}' requires at least \{min} values but only \{got} were provided" + TooManyValues(name, got, max) => + "error: '\{name}' allows at most \{max} values but \{got} were provided" + TooManyPositionals(value, Some(arg)) => + "error: unexpected value '\{value}' for '\{arg}' found; no more were expected" + TooManyPositionals(value, None) => + "error: unexpected value '\{value}' found; no more were expected" + InvalidValue(msg) => "error: \{msg}" + MissingGroup(name) => + "error: the following required argument group was not provided: '\{name}'" + GroupConflict(name) => "error: group conflict \{name}" + } +} + +///| +/// Internal build errors raised while validating command definitions. +priv suberror ArgBuildError { + Unsupported(String) +} + +///| +/// Internal control-flow event for displaying help. +priv suberror DisplayHelp { + Message(String) +} + +///| +/// Internal control-flow event for displaying version text. +priv suberror DisplayVersion { + Message(String) +} diff --git a/argparse/help_render.mbt b/argparse/help_render.mbt new file mode 100644 index 000000000..e1a2cea4d --- /dev/null +++ b/argparse/help_render.mbt @@ -0,0 +1,455 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Render help text for a clap-style command. +fn render_help(cmd : Command) -> String { + render_help_with_usage_override(cmd, None) +} + +///| +fn render_help_with_usage_override( + cmd : Command, + usage_line : String?, +) -> String { + let usage_line = usage_line.unwrap_or("Usage: \{cmd.name}\{usage_tail(cmd)}") + let about = cmd.about.unwrap_or("") + let about_section = if about == "" { + "" + } else { + ( + $| + $| + $|\{about} + ) + } + let commands_section = render_section("Commands:", subcommand_entries(cmd)) + let arguments_section = render_section("Arguments:", positional_entries(cmd)) + let options_section = render_section( + "Options:", + option_entries(cmd), + keep_empty=true, + ) + let groups_section = render_section("Groups:", group_entries(cmd)) + ( + $|\{usage_line}\{about_section}\{commands_section}\{arguments_section}\{options_section}\{groups_section} + $| + ) +} + +///| +fn usage_tail(cmd : Command) -> String { + let mut tail = "" + let required_options = required_option_usage(cmd) + if required_options != "" { + tail = "\{tail} \{required_options}" + } + if has_options(cmd) { + tail = "\{tail} [options]" + } + let pos = positional_usage(cmd) + if pos != "" { + tail = "\{tail} \{pos}" + } + let sub = subcommand_usage(cmd) + if sub != "" { + tail = "\{tail} \{sub}" + } + tail +} + +///| +fn subcommand_usage(cmd : Command) -> String { + if !has_subcommands_for_help(cmd) { + return "" + } + if cmd.subcommand_required { + "" + } else { + "[command]" + } +} + +///| +fn has_options(cmd : Command) -> Bool { + for arg in cmd.args { + if arg.hidden { + continue + } + if arg.info is (OptionInfo(long~, short~, ..) | FlagInfo(long~, short~, ..)) && + (long is Some(_) || short is Some(_)) && + !is_required_arg(arg) { + return true + } + } + false +} + +///| +fn required_option_usage(cmd : Command) -> String { + let parts = Array::new(capacity=cmd.args.length()) + for arg in cmd.args { + if arg.hidden || !is_required_arg(arg) { + continue + } + if arg.info is PositionalInfo(_) { + continue + } + if required_option_token(arg) is Some(token) { + parts.push(token) + } + } + parts.join(" ") +} + +///| +fn required_option_token(arg : Arg) -> String? { + let prefix = match arg.info { + OptionInfo(long~, short~, ..) | FlagInfo(long~, short~, ..) => + if long is Some(long) { + Some("--\{long}") + } else if short is Some(short) { + Some("-\{short}") + } else { + None + } + PositionalInfo(_) => None + } + match prefix { + Some(prefix) => + if arg.info is OptionInfo(_) { + Some("\{prefix} <\{arg.name}>") + } else { + Some(prefix) + } + None => None + } +} + +///| +fn positional_usage(cmd : Command) -> String { + let parts = Array::new(capacity=cmd.args.length()) + for arg in positional_args(cmd.args) { + if arg.hidden { + continue + } + let required = is_required_arg(arg) + if arg.multiple { + if required { + parts.push("<\{arg.name}...>") + } else { + parts.push("[\{arg.name}...]") + } + } else if required { + parts.push("<\{arg.name}>") + } else { + parts.push("[\{arg.name}]") + } + } + parts.join(" ") +} + +///| +fn option_entries(cmd : Command) -> Array[String] { + let args = cmd.args + let display = Array::new(capacity=args.length() + 2) + let builtin_help_short = help_flag_enabled(cmd) && + !has_short_option(args, 'h') + let builtin_help_long = help_flag_enabled(cmd) && + !has_long_option(args, "help") + let builtin_version_short = version_flag_enabled(cmd) && + !has_short_option(args, 'V') + let builtin_version_long = version_flag_enabled(cmd) && + !has_long_option(args, "version") + let builtin_help_label = builtin_option_label( + builtin_help_short, builtin_help_long, "-h", "--help", + ) + if builtin_help_label is Some(label) { + display.push((label, "Show help information.")) + } + let builtin_version_label = builtin_option_label( + builtin_version_short, builtin_version_long, "-V", "--version", + ) + if builtin_version_label is Some(label) { + display.push((label, "Show version information.")) + } + for arg in args { + guard arg.info + is (OptionInfo(long~, short~, ..) | FlagInfo(long~, short~, ..)) && + (long is Some(_) || short is Some(_)) else { + continue + } + if arg.hidden { + continue + } + let name = if arg.info is OptionInfo(_) { + "\{arg_display(arg)} <\{arg.name}>" + } else { + arg_display(arg) + } + display.push((name, arg_doc(arg))) + } + format_entries(display) +} + +///| +fn has_long_option(args : Array[Arg], name : String) -> Bool { + for arg in args { + if arg.info is (OptionInfo(long~, ..) | FlagInfo(long~, ..)) && + long is Some(long) && + long == name { + return true + } + } + false +} + +///| +fn has_short_option(args : Array[Arg], value : Char) -> Bool { + for arg in args { + if arg.info is (OptionInfo(short~, ..) | FlagInfo(short~, ..)) && + short is Some(short) && + short == value { + return true + } + } + false +} + +///| +fn builtin_option_label( + has_short : Bool, + has_long : Bool, + short_label : String, + long_label : String, +) -> String? { + if has_short && has_long { + Some("\{short_label}, \{long_label}") + } else if has_short { + Some(short_label) + } else if has_long { + Some(long_label) + } else { + None + } +} + +///| +fn positional_entries(cmd : Command) -> Array[String] { + let display = Array::new(capacity=cmd.args.length()) + for arg in positional_args(cmd.args) { + if arg.hidden { + continue + } + display.push((positional_display(arg), arg_doc(arg))) + } + format_entries(display) +} + +///| +fn subcommand_entries(cmd : Command) -> Array[String] { + let display = Array::new(capacity=cmd.subcommands.length() + 1) + for sub in cmd.subcommands { + if sub.hidden { + continue + } + display.push((sub.name, sub.about.unwrap_or(""))) + } + if help_subcommand_enabled(cmd) { + display.push(("help", "Print help for the subcommand(s).")) + } + format_entries(display) +} + +///| +fn group_entries(cmd : Command) -> Array[String] { + let display = Array::new(capacity=cmd.groups.length()) + for group in cmd.groups { + let members = group_members(cmd, group) + if members == "" { + continue + } + display.push((group_label(group), members)) + } + format_entries(display) +} + +///| +fn render_section( + header : String, + lines : Array[String], + keep_empty? : Bool = false, +) -> String { + if lines.length() == 0 { + if keep_empty { + ( + $| + $| + $|\{header} + ) + } else { + "" + } + } else { + let body = lines.join("\n") + ( + $| + $| + $|\{header} + $|\{body} + ) + } +} + +///| +fn format_entries(display : Array[(String, String)]) -> Array[String] { + let entries = Array::new(capacity=display.length()) + let mut max_len = 0 + for item in display { + let (name, _) = item + if name.length() > max_len { + max_len = name.length() + } + } + for item in display { + let (name, doc) = item + let padding = " ".repeat(max_len - name.length() + 2) + entries.push(" \{name}\{padding}\{doc}") + } + entries +} + +///| +fn arg_display(arg : Arg) -> String { + let parts = Array::new(capacity=2) + let (short, long) = match arg.info { + OptionInfo(short~, long~, ..) => (short, long) + FlagInfo(short~, long~, ..) => (short, long) + PositionalInfo(_) => (None, None) + } + if short is Some(short) { + parts.push("-\{short}") + } + if long is Some(long) { + if arg.info is FlagInfo(negatable=true, ..) { + parts.push("--[no-]\{long}") + } else { + parts.push("--\{long}") + } + } + if parts.length() == 0 { + arg.name + } else { + parts.join(", ") + } +} + +///| +fn positional_display(arg : Arg) -> String { + if arg.multiple { + "\{arg.name}..." + } else { + arg.name + } +} + +///| +fn arg_doc(arg : Arg) -> String { + let notes = [] + match arg.env { + Some(env_name) => notes.push("[env: \{env_name}]") + None => () + } + if arg.info + is (OptionInfo(default_values~, ..) | PositionalInfo(default_values~, ..)) && + default_values is Some(values) { + if values.length() > 0 { + let defaults = values.join(", ") + notes.push("[default: \{defaults}]") + } + } + let help = arg.about.unwrap_or("") + if help == "" { + notes.join(" ") + } else if notes.length() > 0 { + let notes_text = notes.join(" ") + "\{help} \{notes_text}" + } else { + help + } +} + +///| +fn has_subcommands_for_help(cmd : Command) -> Bool { + if help_subcommand_enabled(cmd) { + return true + } + for sub in cmd.subcommands { + if !sub.hidden { + return true + } + } + false +} + +///| +fn is_required_arg(arg : Arg) -> Bool { + if arg.required { + true + } else { + let (min, _) = arg_min_max(arg) + min > 0 + } +} + +///| +fn group_label(group : ArgGroup) -> String { + let tags = [] + if group.required { + tags.push("[required]") + } + if !group.multiple { + tags.push("[exclusive]") + } + if tags.length() == 0 { + group.name + } else { + let tags_text = tags.join(" ") + "\{group.name} \{tags_text}" + } +} + +///| +fn group_members(cmd : Command, group : ArgGroup) -> String { + let members = [] + for arg in cmd.args { + if arg.hidden { + continue + } + if arg_in_group(arg, group) { + members.push(group_member_display(arg)) + } + } + members.join(", ") +} + +///| +fn group_member_display(arg : Arg) -> String { + let base = arg_display(arg) + match arg.info { + FlagInfo(_) => base + OptionInfo(_) => "\{base} <\{arg.name}>" + PositionalInfo(_) => base + } +} diff --git a/argparse/matches.mbt b/argparse/matches.mbt new file mode 100644 index 000000000..efd40e576 --- /dev/null +++ b/argparse/matches.mbt @@ -0,0 +1,59 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +using @debug {trait Debug, type Repr} + +///| +/// Where a value/flag came from. +pub enum ValueSource { + Argv + Env + Default +} derive(Eq, Show, Debug) + +///| +/// Parse results for declarative commands. +/// +/// - `flags` stores final boolean states for flag args. +/// - `values` stores collected option/positional values. +/// - `flag_counts` stores occurrence counts for `FlagAction::Count`. +/// - `sources` records the final source (`Argv` / `Env` / `Default`) per arg. +/// - `subcommand` stores nested matches for the selected subcommand. +pub struct Matches { + flags : Map[String, Bool] + values : Map[String, Array[String]] + flag_counts : Map[String, Int] + sources : Map[String, ValueSource] + subcommand : (String, Matches)? + priv counts : Map[String, Int] + priv flag_sources : Map[String, ValueSource] + priv value_sources : Map[String, ValueSource] + priv mut parsed_subcommand : (String, Matches)? +} derive(Debug) + +///| +fn new_matches_parse_state() -> Matches { + { + flags: {}, + values: {}, + flag_counts: {}, + sources: {}, + subcommand: None, + counts: {}, + flag_sources: {}, + value_sources: {}, + parsed_subcommand: None, + } +} diff --git a/argparse/moon.pkg b/argparse/moon.pkg new file mode 100644 index 000000000..2b12cb1bf --- /dev/null +++ b/argparse/moon.pkg @@ -0,0 +1,10 @@ +import { + "moonbitlang/core/builtin", + "moonbitlang/core/error", + "moonbitlang/core/env", + "moonbitlang/core/strconv", + "moonbitlang/core/set", + "moonbitlang/core/debug", +} + +warnings = "+unnecessary_annotation" diff --git a/argparse/parser.mbt b/argparse/parser.mbt new file mode 100644 index 000000000..b752af54d --- /dev/null +++ b/argparse/parser.mbt @@ -0,0 +1,686 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn raise_help(text : String) -> Unit raise DisplayHelp { + raise Message(text) +} + +///| +fn raise_version(text : String) -> Unit raise DisplayVersion { + raise Message(text) +} + +///| +fn[T] raise_unknown_long( + name : String, + long_index : Map[String, Arg], +) -> T raise ArgParseError { + let hint = suggest_long(name, long_index) + raise UnknownArgument("--\{name}", hint) +} + +///| +fn[T] raise_unknown_short( + short : Char, + short_index : Map[Char, Arg], +) -> T raise ArgParseError { + let hint = suggest_short(short, short_index) + raise UnknownArgument("-\{short}", hint) +} + +///| +fn[T] raise_subcommand_conflict(name : String) -> T raise ArgParseError { + raise InvalidArgument( + "subcommand '\{name}' cannot be used with positional arguments", + ) +} + +///| +fn help_context_command( + cmd : Command, + inherited_globals : Array[Arg], + command_path : String, +) -> Command { + let help_name = if command_path == "" { cmd.name } else { command_path } + { ..cmd, args: inherited_globals + cmd.args, name: help_name } +} + +///| +fn render_help_for_context( + cmd : Command, + inherited_globals : Array[Arg], + command_path : String, +) -> String { + let help_cmd = help_context_command(cmd, inherited_globals, command_path) + render_help(help_cmd) +} + +///| +fn raise_context_help( + cmd : Command, + inherited_globals : Array[Arg], + command_path : String, +) -> Unit raise DisplayHelp { + raise_help(render_help_for_context(cmd, inherited_globals, command_path)) +} + +///| +fn default_argv() -> Array[String] { + let args = @env.args() + if args.length() > 1 { + args[1:].to_array() + } else { + [] + } +} + +///| +fn merge_global_defs( + inherited_globals : Array[Arg], + globals_here : Array[Arg], +) -> Array[Arg] { + let merged = inherited_globals.copy() + for global in globals_here { + match merged.search_by(arg => arg.name == global.name) { + Some(idx) => merged[idx] = global + None => merged.push(global) + } + } + merged +} + +///| +fn env_resolution_args( + inherited_globals : Array[Arg], + args : Array[Arg], +) -> Array[Arg] { + let merged_globals = merge_global_defs( + inherited_globals, + collect_globals(args), + ) + let local_only = args.filter(arg => { + !(arg.global && arg.info is (FlagInfo(_) | OptionInfo(_))) + }) + merged_globals + local_only +} + +///| +fn format_error_with_help( + msg : String, + cmd : Command, + inherited_globals : Array[Arg], + command_path : String, +) -> String { + "\{msg}\n\n\{render_help_for_context(cmd, inherited_globals, command_path)}" +} + +///| +fn arg_usage_token_for_group(arg : Arg) -> String { + let base = match arg.info { + OptionInfo(long~, short~, ..) | FlagInfo(long~, short~, ..) => + if long is Some(long) { + "--\{long}" + } else if short is Some(short) { + "-\{short}" + } else { + arg.name + } + PositionalInfo(_) => + if arg.multiple { + "<\{arg.name}...>" + } else { + "<\{arg.name}>" + } + } + if arg.info is OptionInfo(_) { + "\{base} <\{arg.name}>" + } else { + base + } +} + +///| +fn group_usage_expr( + groups : Array[ArgGroup], + args : Array[Arg], + name : String, +) -> String? { + for group in groups { + if group.name != name { + continue + } + let members = [] + for member_name in group.args { + if args.search_by(arg => arg.name == member_name) is Some(idx) { + let item = args[idx] + if item.hidden { + continue + } + members.push(arg_usage_token_for_group(item)) + } + } + if members.length() == 0 { + return None + } + let joined = members.join("|") + return Some("<\{joined}>") + } + None +} + +///| +fn missing_group_error_message( + cmd : Command, + inherited_globals : Array[Arg], + command_path : String, + group_name : String, +) -> String { + let help_cmd = help_context_command(cmd, inherited_globals, command_path) + if group_usage_expr(help_cmd.groups, help_cmd.args, group_name) is Some(expr) { + ( + $|error: the following required arguments were not provided: + $| \{expr} + ) + } else { + MissingGroup(group_name).arg_parse_error_message() + } +} + +///| +fn arg_error_for_parse_failure( + err : ArgParseError, + cmd : Command, + inherited_globals : Array[Arg], + command_path : String, +) -> ArgError { + match err { + MissingGroup(name) => + Message( + format_error_with_help( + missing_group_error_message( + cmd, inherited_globals, command_path, name, + ), + cmd, + inherited_globals, + command_path, + ), + ) + GroupConflict(_) => + Message( + format_error_with_help( + err.arg_parse_error_message(), + cmd, + inherited_globals, + command_path, + ), + ) + _ => + Message( + format_error_with_help( + err.arg_parse_error_message(), + cmd, + inherited_globals, + command_path, + ), + ) + } +} + +///| +fn parse_command( + cmd : Command, + argv : ArrayView[String], + env : Map[String, String], + inherited_globals : Array[Arg], + inherited_version_long : Map[String, String], + inherited_version_short : Map[Char, String], + command_path : String, + seed_matches? : Matches = new_matches_parse_state(), +) -> Matches raise { + parse_command_impl( + cmd, argv, env, inherited_globals, inherited_version_long, inherited_version_short, + command_path, seed_matches, + ) catch { + UnknownArgument(arg, hint) => + raise arg_error_for_parse_failure( + UnknownArgument(arg, hint), + cmd, + inherited_globals, + command_path, + ) + InvalidArgument(msg) => + raise arg_error_for_parse_failure( + InvalidArgument(msg), + cmd, + inherited_globals, + command_path, + ) + MissingValue(name) => + raise arg_error_for_parse_failure( + MissingValue(name), + cmd, + inherited_globals, + command_path, + ) + MissingRequired(name, by) => + raise arg_error_for_parse_failure( + MissingRequired(name, by), + cmd, + inherited_globals, + command_path, + ) + TooFewValues(name, got, min) => + raise arg_error_for_parse_failure( + TooFewValues(name, got, min), + cmd, + inherited_globals, + command_path, + ) + TooManyValues(name, got, max) => + raise arg_error_for_parse_failure( + TooManyValues(name, got, max), + cmd, + inherited_globals, + command_path, + ) + TooManyPositionals(value, arg) => + raise arg_error_for_parse_failure( + TooManyPositionals(value, arg), + cmd, + inherited_globals, + command_path, + ) + InvalidValue(msg) => + raise arg_error_for_parse_failure( + InvalidValue(msg), + cmd, + inherited_globals, + command_path, + ) + MissingGroup(name) => + raise arg_error_for_parse_failure( + MissingGroup(name), + cmd, + inherited_globals, + command_path, + ) + GroupConflict(name) => + raise arg_error_for_parse_failure( + GroupConflict(name), + cmd, + inherited_globals, + command_path, + ) + Unsupported(msg) => + raise ArgError::Message( + "error: command definition validation failed: \{msg}", + ) + err => raise err + } +} + +///| +fn parse_command_impl( + cmd : Command, + argv : ArrayView[String], + env : Map[String, String], + inherited_globals : Array[Arg], + inherited_version_long : Map[String, String], + inherited_version_short : Map[Char, String], + command_path : String, + seed_matches : Matches, +) -> Matches raise { + match cmd.build_error { + Some(err) => raise err + None => () + } + let args = cmd.args + let groups = cmd.groups + let subcommands = cmd.subcommands + if cmd.arg_required_else_help && argv.length() == 0 { + raise_context_help(cmd, inherited_globals, command_path) + } + let matches = seed_matches + let globals_here = collect_globals(args) + let child_globals = merge_global_defs(inherited_globals, globals_here) + let child_version_long = inherited_version_long.copy() + let child_version_short = inherited_version_short.copy() + for global in globals_here { + if global.info is FlagInfo(long~, short~, action=Version, ..) { + if long is Some(name) { + child_version_long[name] = command_version(cmd) + } + if short is Some(short) { + child_version_short[short] = command_version(cmd) + } + } + } + let long_index = build_long_index(inherited_globals, args) + let short_index = build_short_index(inherited_globals, args) + let builtin_help_short = help_flag_enabled(cmd) && + short_index.get('h') is None + let builtin_help_long = help_flag_enabled(cmd) && + long_index.get("help") is None + let builtin_version_short = version_flag_enabled(cmd) && + short_index.get('V') is None + let builtin_version_long = version_flag_enabled(cmd) && + long_index.get("version") is None + let positionals = positional_args(args) + let positional_values = [] + let mut i = 0 + let mut positional_arg_found = false + while i < argv.length() { + let arg = argv[i] + if arg == "--" { + if i + 1 < argv.length() { + positional_arg_found = true + } + for rest in argv[i + 1:] { + positional_values.push(rest) + } + break + } + if builtin_help_short && arg == "-h" { + raise_context_help(cmd, inherited_globals, command_path) + } + if builtin_help_long && arg == "--help" { + raise_context_help(cmd, inherited_globals, command_path) + } + if builtin_version_short && arg == "-V" { + raise_version(command_version(cmd)) + } + if builtin_version_long && arg == "--version" { + raise_version(command_version(cmd)) + } + if should_parse_as_positional( + arg, positionals, positional_values, long_index, short_index, + ) { + positional_values.push(arg) + positional_arg_found = true + i = i + 1 + continue + } + if arg.has_prefix("--") { + let (name, inline) = split_long(arg) + if builtin_help_long && name == "help" { + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + raise_context_help(cmd, inherited_globals, command_path) + } + if builtin_version_long && name == "version" { + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + raise_version(command_version(cmd)) + } + match long_index.get(name) { + None => + // Support `--no-` when the underlying flag is marked `negatable`. + if name is [.. "no-", .. target] && + long_index.get(target.to_string()) + is Some({ info: FlagInfo(negatable=true, action~, ..), .. } as spec) { + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + let value = match action { + SetFalse => true + _ => false + } + if action is Count { + matches.counts[spec.name] = 0 + } + matches.flags[spec.name] = value + matches.flag_sources[spec.name] = Argv + } else { + raise_unknown_long(name, long_index) + } + Some(spec) => + if spec.info is (OptionInfo(_) | PositionalInfo(_)) { + check_duplicate_set_occurrence(matches, spec) + if inline is Some(v) { + assign_value(matches, spec, v, Argv) + } else { + let can_take_next = i + 1 < argv.length() && + !should_stop_option_value( + argv[i + 1], + spec, + long_index, + short_index, + ) + if can_take_next { + i = i + 1 + assign_value(matches, spec, argv[i], Argv) + } else { + raise ArgParseError::MissingValue("--\{name}") + } + } + } else { + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + match spec.info { + FlagInfo(action=Help, ..) => + raise_context_help(cmd, inherited_globals, command_path) + FlagInfo(action=Version, ..) => + raise_version( + version_text_for_long_action( + cmd, name, inherited_version_long, + ), + ) + _ => apply_flag(matches, spec, Argv) + } + } + } + i = i + 1 + continue + } + if arg.has_prefix("-") && arg != "-" { + // Parse short groups like `-abc` and short values like `-c3`. + let chars = arg.iter() + ignore(chars.next()) + let mut consumed_next = false + while chars.next() is Some(short) { + if short == 'h' && builtin_help_short { + raise_context_help(cmd, inherited_globals, command_path) + } + if short == 'V' && builtin_version_short { + raise_version(command_version(cmd)) + } + let spec = match short_index.get(short) { + Some(v) => v + None => raise_unknown_short(short, short_index) + } + if spec.info is (OptionInfo(_) | PositionalInfo(_)) { + check_duplicate_set_occurrence(matches, spec) + let rest = String::from_iter(chars) + if rest != "" { + let inline = match rest.strip_prefix("=") { + Some(view) => view.to_string() + None => rest + } + assign_value(matches, spec, inline, Argv) + } else { + let can_take_next = i + 1 < argv.length() && + !should_stop_option_value( + argv[i + 1], + spec, + long_index, + short_index, + ) + if can_take_next { + consumed_next = true + assign_value(matches, spec, argv[i + 1], Argv) + } else { + raise ArgParseError::MissingValue("-\{short}") + } + } + break + } else { + match spec.info { + FlagInfo(action=Help, ..) => + raise_context_help(cmd, inherited_globals, command_path) + FlagInfo(action=Version, ..) => + raise_version( + version_text_for_short_action( + cmd, short, inherited_version_short, + ), + ) + _ => apply_flag(matches, spec, Argv) + } + } + } + i = i + 1 + (if consumed_next { 1 } else { 0 }) + continue + } + if help_subcommand_enabled(cmd) && arg == "help" { + if positional_arg_found { + raise_subcommand_conflict("help") + } + let rest = argv[i + 1:].to_array() + let (target, target_globals, target_path) = resolve_help_target( + cmd, rest, builtin_help_short, builtin_help_long, inherited_globals, command_path, + ) + let text = render_help_for_context(target, target_globals, target_path) + raise_help(text) + } + if subcommands.iter().find_first(sub => sub.name == arg) is Some(sub) { + if positional_arg_found { + raise_subcommand_conflict(sub.name) + } + let rest = argv[i + 1:].to_array() + let sub_path = if command_path == "" { + sub.name + } else { + "\{command_path} \{sub.name}" + } + let child_local_non_globals = collect_non_global_names(sub.args) + let child_seed = seed_child_globals_from_parent( + matches, child_globals, child_local_non_globals, + ) + let sub_matches = parse_command( + sub, + rest, + env, + child_globals, + child_version_long, + child_version_short, + sub_path, + seed_matches=child_seed, + ) + matches.parsed_subcommand = Some((sub.name, sub_matches)) + // Merge argv-provided globals from the subcommand parse into the parent + // so globals work even when they appear after the subcommand name. + merge_globals_from_child( + matches, sub_matches, child_globals, child_local_non_globals, + ) + let env_args = env_resolution_args(inherited_globals, args) + let parent_matches = finalize_matches( + cmd, args, groups, matches, positionals, positional_values, env_args, env, + ) + validate_relationships(parent_matches, args) + match parent_matches.parsed_subcommand { + Some((sub_name, sub_m)) => { + // After parent parsing, copy the final globals into the subcommand. + propagate_globals_to_child( + parent_matches, sub_m, child_globals, child_local_non_globals, + ) + parent_matches.parsed_subcommand = Some((sub_name, sub_m)) + } + None => () + } + return parent_matches + } + + positional_values.push(arg) + positional_arg_found = true + i = i + 1 + } + let env_args = env_resolution_args(inherited_globals, args) + let final_matches = finalize_matches( + cmd, args, groups, matches, positionals, positional_values, env_args, env, + ) + validate_relationships(final_matches, args) + final_matches +} + +///| +fn finalize_matches( + cmd : Command, + args : Array[Arg], + groups : Array[ArgGroup], + matches : Matches, + positionals : Array[Arg], + positional_values : Array[String], + env_args : Array[Arg], + env : Map[String, String], +) -> Matches raise ArgParseError { + assign_positionals(matches, positionals, positional_values) + apply_env(matches, env_args, env) + apply_defaults(matches, env_args) + validate_values(args, matches) + validate_groups(args, groups, matches) + validate_command_policies(cmd, matches) + matches +} + +///| +fn help_subcommand_enabled(cmd : Command) -> Bool { + !cmd.disable_help_subcommand && cmd.subcommands.length() > 0 +} + +///| +fn help_flag_enabled(cmd : Command) -> Bool { + !cmd.disable_help_flag +} + +///| +fn version_flag_enabled(cmd : Command) -> Bool { + !cmd.disable_version_flag && cmd.version is Some(_) +} + +///| +fn command_version(cmd : Command) -> String { + cmd.version.unwrap_or("") +} + +///| +fn version_text_for_long_action( + cmd : Command, + long : String, + inherited_version_long : Map[String, String], +) -> String { + for arg in cmd.args { + if arg.info is FlagInfo(long=Some(name), action=Version, ..) && name == long { + return command_version(cmd) + } + } + inherited_version_long.get(long).unwrap_or(command_version(cmd)) +} + +///| +fn version_text_for_short_action( + cmd : Command, + short : Char, + inherited_version_short : Map[Char, String], +) -> String { + for arg in cmd.args { + if arg.info is FlagInfo(short=Some(value), action=Version, ..) && + value == short { + return command_version(cmd) + } + } + inherited_version_short.get(short).unwrap_or(command_version(cmd)) +} diff --git a/argparse/parser_globals_merge.mbt b/argparse/parser_globals_merge.mbt new file mode 100644 index 000000000..003527e4a --- /dev/null +++ b/argparse/parser_globals_merge.mbt @@ -0,0 +1,321 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn source_priority(source : ValueSource?) -> Int { + match source { + Some(Argv) => 3 + Some(Env) => 2 + Some(Default) => 1 + None => 0 + } +} + +///| +fn prefer_child_source( + parent_source : ValueSource?, + child_source : ValueSource?, +) -> Bool { + let parent_priority = source_priority(parent_source) + let child_priority = source_priority(child_source) + if child_priority > parent_priority { + true + } else if child_priority < parent_priority { + false + } else { + child_source is Some(Argv) + } +} + +///| +fn strongest_source( + parent_source : ValueSource?, + child_source : ValueSource?, +) -> ValueSource? { + if prefer_child_source(parent_source, child_source) { + child_source + } else { + match parent_source { + Some(source) => Some(source) + None => child_source + } + } +} + +///| +fn merge_global_value_from_child( + parent : Matches, + child : Matches, + arg : Arg, + name : String, +) -> Unit raise ArgParseError { + let parent_vals = parent.values.get(name) + let child_vals = child.values.get(name) + let parent_source = parent.value_sources.get(name) + let child_source = child.value_sources.get(name) + let has_parent = parent_vals is Some(pv) && pv.length() > 0 + let has_child = child_vals is Some(cv) && cv.length() > 0 + if !has_parent && !has_child { + return + } + if arg.multiple || arg.info is OptionInfo(action=Append, ..) { + let both_argv = parent_source is Some(Argv) && child_source is Some(Argv) + if both_argv { + let merged = [] + if parent_vals is Some(pv) { + for v in pv { + merged.push(v) + } + } + if child_vals is Some(cv) { + for v in cv { + merged.push(v) + } + } + if merged.length() > 0 { + parent.values[name] = merged + parent.value_sources[name] = Argv + } + } else { + let choose_child = has_child && + (!has_parent || prefer_child_source(parent_source, child_source)) + if choose_child { + if child_vals is Some(cv) && cv.length() > 0 { + parent.values[name] = cv.copy() + } + match child_source { + Some(src) => parent.value_sources[name] = src + None => () + } + } else if parent_vals is Some(pv) && pv.length() > 0 { + parent.values[name] = pv.copy() + match parent_source { + Some(src) => parent.value_sources[name] = src + None => () + } + } + } + } else { + if has_parent && + has_child && + parent_source is Some(Argv) && + child_source is Some(Argv) && + arg.info is OptionInfo(action=Set, ..) { + raise InvalidArgument( + "argument '\{global_option_conflict_label(arg)}' cannot be used multiple times", + ) + } + let choose_child = has_child && + (!has_parent || prefer_child_source(parent_source, child_source)) + if choose_child { + if child_vals is Some(cv) && cv.length() > 0 { + parent.values[name] = cv.copy() + } + match child_source { + Some(src) => parent.value_sources[name] = src + None => () + } + } else if parent_vals is Some(pv) && pv.length() > 0 { + parent.values[name] = pv.copy() + match parent_source { + Some(src) => parent.value_sources[name] = src + None => () + } + } + } +} + +///| +fn merge_global_flag_from_child( + parent : Matches, + child : Matches, + arg : Arg, + name : String, +) -> Unit { + match child.flags.get(name) { + Some(v) => + if arg.info is FlagInfo(action=Count, ..) { + let has_parent = parent.flags.get(name) is Some(_) + let parent_source = parent.flag_sources.get(name) + let child_source = child.flag_sources.get(name) + let both_argv = parent_source is Some(Argv) && + child_source is Some(Argv) + if both_argv { + let parent_count = parent.counts.get(name).unwrap_or(0) + let child_count = child.counts.get(name).unwrap_or(0) + if child_count == 0 && !v { + // `--no-` after a subcommand should reset an earlier argv count. + parent.counts[name] = 0 + parent.flags[name] = false + } else { + let total = parent_count + child_count + parent.counts[name] = total + parent.flags[name] = total > 0 + } + match strongest_source(parent_source, child_source) { + Some(src) => parent.flag_sources[name] = src + None => () + } + } else { + let choose_child = !has_parent || + prefer_child_source(parent_source, child_source) + if choose_child { + let child_count = child.counts.get(name).unwrap_or(0) + parent.counts[name] = child_count + parent.flags[name] = child_count > 0 + match child_source { + Some(src) => parent.flag_sources[name] = src + None => () + } + } + } + } else { + let has_parent = parent.flags.get(name) is Some(_) + let parent_source = parent.flag_sources.get(name) + let child_source = child.flag_sources.get(name) + let choose_child = !has_parent || + prefer_child_source(parent_source, child_source) + if choose_child { + parent.flags[name] = v + match child_source { + Some(src) => parent.flag_sources[name] = src + None => () + } + } + } + None => () + } +} + +///| +fn seed_child_globals_from_parent( + parent : Matches, + globals : Array[Arg], + child_local_non_globals : @set.Set[String], +) -> Matches { + let seed = new_matches_parse_state() + let seen : @set.Set[String] = @set.new() + for arg in globals { + let name = arg.name + if child_local_non_globals.contains(name) || !seen.add_and_check(name) { + continue + } + if arg.info is (OptionInfo(_) | PositionalInfo(_)) { + if parent.values.get(name) is Some(_) && + parent.value_sources.get(name) is Some(Argv) { + // Presence marker only: avoid duplicating parent argv values in child. + seed.values[name] = [] + seed.value_sources[name] = Argv + } + continue + } + if parent.flags.get(name) is Some(v) && + parent.flag_sources.get(name) is Some(Argv) { + seed.flags[name] = v + seed.flag_sources[name] = Argv + if arg.info is FlagInfo(action=Count, ..) { + // Presence marker only: count is rebuilt from child argv tokens. + seed.counts[name] = 0 + } + } + } + seed +} + +///| +fn merge_globals_from_child( + parent : Matches, + child : Matches, + globals : Array[Arg], + child_local_non_globals : @set.Set[String], +) -> Unit raise ArgParseError { + for arg in globals { + let name = arg.name + if child_local_non_globals.contains(name) { + continue + } + match arg.info { + OptionInfo(_) | PositionalInfo(_) => + merge_global_value_from_child(parent, child, arg, name) + FlagInfo(_) => merge_global_flag_from_child(parent, child, arg, name) + } + } +} + +///| +fn global_option_conflict_label(arg : Arg) -> String { + match arg.info { + FlagInfo(long~, short~, ..) | OptionInfo(long~, short~, ..) => + match long { + Some(name) => "--\{name}" + None => + match short { + Some(short) => "-\{short}" + None => arg.name + } + } + PositionalInfo(_) => arg.name + } +} + +///| +fn propagate_globals_to_child( + parent : Matches, + child : Matches, + globals : Array[Arg], + child_local_non_globals : @set.Set[String], +) -> Unit { + for arg in globals { + let name = arg.name + if child_local_non_globals.contains(name) { + continue + } + if arg.info is (OptionInfo(_) | PositionalInfo(_)) { + match parent.values.get(name) { + Some(values) => { + child.values[name] = values.copy() + match parent.value_sources.get(name) { + Some(src) => child.value_sources[name] = src + None => () + } + } + None => () + } + } else { + match parent.flags.get(name) { + Some(v) => { + child.flags[name] = v + match parent.flag_sources.get(name) { + Some(src) => child.flag_sources[name] = src + None => () + } + if arg.info is FlagInfo(action=Count, ..) { + match parent.counts.get(name) { + Some(c) => child.counts[name] = c + None => () + } + } + } + None => () + } + } + } + match child.parsed_subcommand { + Some((sub_name, sub_m)) => { + propagate_globals_to_child(parent, sub_m, globals, @set.new()) + child.parsed_subcommand = Some((sub_name, sub_m)) + } + None => () + } +} diff --git a/argparse/parser_lookup.mbt b/argparse/parser_lookup.mbt new file mode 100644 index 000000000..f3b4f62bf --- /dev/null +++ b/argparse/parser_lookup.mbt @@ -0,0 +1,118 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn build_long_index( + globals : Array[Arg], + args : Array[Arg], +) -> Map[String, Arg] { + let index : Map[String, Arg] = {} + for arg in globals.iter() + args.iter() { + if arg.info is (FlagInfo(long~, ..) | OptionInfo(long~, ..)) && + long is Some(name) { + index[name] = arg + } + } + index +} + +///| +fn build_short_index(globals : Array[Arg], args : Array[Arg]) -> Map[Char, Arg] { + let index : Map[Char, Arg] = {} + for arg in globals { + if arg.info is (FlagInfo(short~, ..) | OptionInfo(short~, ..)) && + short is Some(value) { + index[value] = arg + } + } + for arg in args { + if arg.info is (FlagInfo(short~, ..) | OptionInfo(short~, ..)) && + short is Some(value) { + index[value] = arg + } + } + index +} + +///| +fn collect_globals(args : Array[Arg]) -> Array[Arg] { + args.filter(arg => arg.global && arg.info is (FlagInfo(_) | OptionInfo(_))) +} + +///| +fn collect_non_global_names(args : Array[Arg]) -> @set.Set[String] { + @set.from_iter(args.iter().filter(arg => !arg.global).map(arg => arg.name)) +} + +///| +fn resolve_help_target( + cmd : Command, + argv : Array[String], + builtin_help_short : Bool, + builtin_help_long : Bool, + inherited_globals : Array[Arg], + command_path : String, +) -> (Command, Array[Arg], String) raise ArgParseError { + let targets = match argv { + [.. pre, "-h"] if builtin_help_short => pre + [.. pre, "--help"] if builtin_help_long => pre + _ => argv + } + let mut current = cmd + let mut current_path = command_path + let mut current_globals = inherited_globals + let mut subs = cmd.subcommands + for name in targets { + if name.has_prefix("-") { + raise InvalidArgument("unexpected help argument: \{name}") + } + guard subs.iter().find_first(sub => sub.name == name) is Some(sub) else { + raise InvalidArgument("unknown subcommand: \{name}") + } + current_globals = merge_global_defs( + current_globals, + collect_globals(current.args), + ) + current = sub + current_path = if current_path == "" { + sub.name + } else { + "\{current_path} \{sub.name}" + } + subs = sub.subcommands + } + (current, current_globals, current_path) +} + +///| +fn split_long(arg : String) -> (String, String?) { + let parts = [] + for part in arg.split("=") { + parts.push(part.to_string()) + } + if parts.length() <= 1 { + let name = match parts[0].strip_prefix("--") { + Some(view) => view.to_string() + None => parts[0] + } + (name, None) + } else { + let name = match parts[0].strip_prefix("--") { + Some(view) => view.to_string() + None => parts[0] + } + let value = parts[1:].to_array().join("=") + (name, Some(value)) + } +} diff --git a/argparse/parser_lookup_wbtest.mbt b/argparse/parser_lookup_wbtest.mbt new file mode 100644 index 000000000..7d5a80dd8 --- /dev/null +++ b/argparse/parser_lookup_wbtest.mbt @@ -0,0 +1,42 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "resolve_help_target merges inherited globals by name" { + let leaf = Command("leaf") + let mid = Command( + "mid", + options=[OptionArg("mode", long="mid-mode", global=true)], + subcommands=[leaf], + ) + let root = Command( + "demo", + options=[OptionArg("mode", long="root-mode", global=true)], + subcommands=[mid], + ) + + let (target, globals, path) = resolve_help_target( + root, + ["mid", "leaf"], + true, + true, + [], + "demo", + ) catch { + _ => panic() + } + let help = render_help_for_context(target, globals, path) + assert_true(help.contains("--mid-mode ")) + assert_true(!help.contains("--root-mode ")) +} diff --git a/argparse/parser_positionals.mbt b/argparse/parser_positionals.mbt new file mode 100644 index 000000000..cdcd5407c --- /dev/null +++ b/argparse/parser_positionals.mbt @@ -0,0 +1,118 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn positional_args(args : Array[Arg]) -> Array[Arg] { + let ordered = [] + for arg in args { + if arg.info is PositionalInfo(_) { + ordered.push(arg) + } + } + ordered +} + +///| +fn next_positional(positionals : Array[Arg], collected : Array[String]) -> Arg? { + let target = collected.length() + let total = target + 1 + let mut cursor = 0 + for idx in 0..= total { + break + } + let arg = positionals[idx] + let remaining = total - cursor + let take = if arg.multiple { + let (min, max) = arg_min_max(arg) + let reserve = remaining_positional_min(positionals, idx + 1) + let mut take = remaining - reserve + if take < 0 { + take = 0 + } + match max { + Some(max_count) if take > max_count => take = max_count + _ => () + } + if take < min { + take = min + } + if take > remaining { + take = remaining + } + take + } else if remaining > 0 { + 1 + } else { + 0 + } + if take > 0 && target < cursor + take { + return Some(arg) + } + cursor = cursor + take + } + None +} + +///| +fn should_parse_as_positional( + arg : String, + positionals : Array[Arg], + collected : Array[String], + long_index : Map[String, Arg], + short_index : Map[Char, Arg], +) -> Bool { + if !arg.has_prefix("-") || arg == "-" { + return false + } + let next = match next_positional(positionals, collected) { + Some(v) => v + None => return false + } + let next_allow = match next.info { + FlagInfo(_) => false + OptionInfo(allow_hyphen_values~, ..) + | PositionalInfo(allow_hyphen_values~, ..) => allow_hyphen_values + } + let allow = next_allow || is_negative_number(arg) + if !allow { + return false + } + if arg.has_prefix("--") { + let (name, _) = split_long(arg) + return long_index.get(name) is None + } + let short = arg.get_char(1) + match short { + Some(ch) => short_index.get(ch) is None + None => true + } +} + +///| +fn is_negative_number(arg : String) -> Bool { + if arg.length() < 2 { + return false + } + guard arg.get_char(0) is Some('-') else { return false } + let mut i = 1 + while i < arg.length() { + let ch = arg.get_char(i).unwrap() + if ch < '0' || ch > '9' { + return false + } + i = i + ch.utf16_len() + } + true +} diff --git a/argparse/parser_suggest.mbt b/argparse/parser_suggest.mbt new file mode 100644 index 000000000..44bcdcf05 --- /dev/null +++ b/argparse/parser_suggest.mbt @@ -0,0 +1,115 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn suggest_long(name : String, long_index : Map[String, Arg]) -> String? { + let candidates = long_index.keys().collect() + if suggest_name(name, candidates) is Some(best) { + Some("--\{best}") + } else { + None + } +} + +///| +fn suggest_short(short : Char, short_index : Map[Char, Arg]) -> String? { + let candidates = short_index.keys().map(Char::to_string).collect() + let input = short.to_string() + if suggest_name(input, candidates) is Some(best) { + Some("-\{best}") + } else { + None + } +} + +///| +fn suggest_name(input : String, candidates : Array[String]) -> String? { + let mut best : String? = None + let mut best_dist = 0 + let mut has_best = false + let max_dist = suggestion_threshold(input.length()) + for cand in candidates { + let dist = levenshtein(input, cand) + if !has_best || dist < best_dist { + best_dist = dist + best = Some(cand) + has_best = true + } + } + match best { + Some(name) if best_dist <= max_dist => Some(name) + _ => None + } +} + +///| +fn suggestion_threshold(len : Int) -> Int { + if len <= 4 { + 1 + } else if len <= 8 { + 2 + } else { + 3 + } +} + +///| +fn levenshtein(a : String, b : String) -> Int { + let aa = a.to_array() + let bb = b.to_array() + let m = aa.length() + let n = bb.length() + if m == 0 { + return n + } + if n == 0 { + return m + } + let mut prev = Array::new(capacity=n + 1) + let mut curr = Array::new(capacity=n + 1) + let mut j = 0 + while j <= n { + prev.push(j) + curr.push(0) + j = j + 1 + } + let mut i = 1 + while i <= m { + curr[0] = i + let mut j2 = 1 + while j2 <= n { + let cost = if aa[i - 1] == bb[j2 - 1] { 0 } else { 1 } + let del = prev[j2] + 1 + let ins = curr[j2 - 1] + 1 + let sub = prev[j2 - 1] + cost + curr[j2] = if del < ins { + if del < sub { + del + } else { + sub + } + } else if ins < sub { + ins + } else { + sub + } + j2 = j2 + 1 + } + let temp = prev + prev = curr + curr = temp + i = i + 1 + } + prev[n] +} diff --git a/argparse/parser_validate.mbt b/argparse/parser_validate.mbt new file mode 100644 index 000000000..d0f1b9a8a --- /dev/null +++ b/argparse/parser_validate.mbt @@ -0,0 +1,614 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +priv struct ValidationCtx { + inherited_global_names : @set.Set[String] + seen_names : @set.Set[String] + seen_long : @set.Set[String] + seen_short : @set.Set[Char] + args : Array[Arg] +} + +///| +fn ValidationCtx::new( + inherited_global_names? : @set.Set[String] = @set.new(), +) -> ValidationCtx { + { + inherited_global_names: inherited_global_names.copy(), + seen_names: @set.new(), + seen_long: @set.new(), + seen_short: @set.new(), + args: [], + } +} + +///| +fn ValidationCtx::record_arg( + self : ValidationCtx, + arg : Arg, +) -> Unit raise ArgBuildError { + if !self.seen_names.add_and_check(arg.name) { + raise Unsupported("duplicate arg name: \{arg.name}") + } + if !arg.global && self.inherited_global_names.contains(arg.name) { + raise Unsupported( + "arg '\{arg.name}' shadows an inherited global; rename the arg or mark it global", + ) + } + fn check_long(name) raise _ { + if !self.seen_long.add_and_check(name) { + raise ArgBuildError::Unsupported("duplicate long option: --\{name}") + } + } + fn check_short(short) raise _ { + if !self.seen_short.add_and_check(short) { + raise ArgBuildError::Unsupported("duplicate short option: -\{short}") + } + } + match arg.info { + FlagInfo(long~, short~, negatable~, ..) => { + if long is Some(name) { + check_long(name) + if negatable { + check_long("no-\{name}") + } + } + if short is Some(short) { + check_short(short) + } + } + OptionInfo(long~, short~, ..) => { + if long is Some(name) { + check_long(name) + } + if short is Some(short) { + check_short(short) + } + } + PositionalInfo(_) => () + } + self.args.push(arg) +} + +///| +fn ValidationCtx::finalize(self : ValidationCtx) -> Unit raise ArgBuildError { + validate_requires_conflicts_targets(self.args, self.seen_names) +} + +///| +fn validate_command( + cmd : Command, + args : Array[Arg], + groups : Array[ArgGroup], + inherited_globals : Array[Arg], +) -> Unit raise ArgBuildError { + match cmd.build_error { + Some(err) => raise err + None => () + } + validate_inherited_global_shadowing(args, inherited_globals) + validate_group_defs(args, groups) + validate_group_refs(args, groups) + validate_subcommand_defs(cmd.subcommands) + validate_subcommand_required_policy(cmd) + validate_help_subcommand(cmd) + validate_version_actions(cmd) + let child_inherited_globals = merge_inherited_globals( + inherited_globals, + collect_globals(args), + ) + for sub in cmd.subcommands { + validate_command(sub, sub.args, sub.groups, child_inherited_globals) + } +} + +///| +fn validate_inherited_global_shadowing( + args : Array[Arg], + inherited_globals : Array[Arg], +) -> Unit raise ArgBuildError { + for arg in args { + if inherited_globals.iter().find_first(g => g.name == arg.name) + is Some(inherited_arg) { + if !arg.global { + raise Unsupported( + "arg '\{arg.name}' shadows an inherited global; rename the arg or mark it global", + ) + } + if !global_override_compatible(inherited_arg, arg) { + raise Unsupported( + "global arg '\{arg.name}' is incompatible with inherited global definition", + ) + } + } + match arg.info { + FlagInfo(long~, short~, negatable~, ..) => { + if long is Some(name) { + validate_inherited_global_long_collision(arg, name, inherited_globals) + if negatable { + validate_inherited_global_long_collision( + arg, + "no-\{name}", + inherited_globals, + ) + } + } + if short is Some(value) { + validate_inherited_global_short_collision( + arg, value, inherited_globals, + ) + } + } + OptionInfo(long~, short~, ..) => { + if long is Some(name) { + validate_inherited_global_long_collision(arg, name, inherited_globals) + } + if short is Some(value) { + validate_inherited_global_short_collision( + arg, value, inherited_globals, + ) + } + } + PositionalInfo(_) => () + } + } +} + +///| +fn merge_inherited_globals( + inherited_globals : Array[Arg], + globals_here : Array[Arg], +) -> Array[Arg] { + let merged = inherited_globals.copy() + for global in globals_here { + match merged.search_by(arg => arg.name == global.name) { + Some(idx) => merged[idx] = global + None => merged.push(global) + } + } + merged +} + +///| +fn global_override_compatible(inherited_arg : Arg, arg : Arg) -> Bool { + match inherited_arg.info { + FlagInfo(action=inherited_action, negatable=inherited_negatable, ..) => + match arg.info { + FlagInfo(action~, negatable~, ..) => + inherited_action == action && inherited_negatable == negatable + _ => false + } + OptionInfo(action=inherited_action, ..) => + match arg.info { + OptionInfo(action~, ..) => inherited_action == action + _ => false + } + PositionalInfo(_) => false + } +} + +///| +fn validate_inherited_global_long_collision( + arg : Arg, + long : String, + inherited_globals : Array[Arg], +) -> Unit raise ArgBuildError { + if inherited_global_long_owner(arg.name, long, inherited_globals) + is Some(owner) { + raise Unsupported( + "arg '\{arg.name}' long option --\{long} conflicts with inherited global '\{owner}'", + ) + } +} + +///| +fn validate_inherited_global_short_collision( + arg : Arg, + short : Char, + inherited_globals : Array[Arg], +) -> Unit raise ArgBuildError { + if inherited_global_short_owner(arg.name, short, inherited_globals) + is Some(owner) { + raise Unsupported( + "arg '\{arg.name}' short option -\{short} conflicts with inherited global '\{owner}'", + ) + } +} + +///| +fn inherited_global_long_owner( + current_name : String, + long : String, + inherited_globals : Array[Arg], +) -> String? { + for inherited in inherited_globals { + if inherited.name == current_name { + continue + } + match inherited.info { + FlagInfo(long=inherited_long, negatable~, ..) => + if inherited_long is Some(name) && + (name == long || (negatable && "no-\{name}" == long)) { + return Some(inherited.name) + } + OptionInfo(long=inherited_long, ..) => + if inherited_long is Some(name) && name == long { + return Some(inherited.name) + } + PositionalInfo(_) => () + } + } + None +} + +///| +fn inherited_global_short_owner( + current_name : String, + short : Char, + inherited_globals : Array[Arg], +) -> String? { + for inherited in inherited_globals { + if inherited.name == current_name { + continue + } + match inherited.info { + FlagInfo(short=inherited_short, ..) + | OptionInfo(short=inherited_short, ..) => + if inherited_short is Some(value) && value == short { + return Some(inherited.name) + } + PositionalInfo(_) => () + } + } + None +} + +///| +fn validate_flag_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + validate_named_option_arg(arg) + guard arg.info is FlagInfo(action~, negatable~, ..) + if action is (Help | Version) { + guard !negatable else { + raise Unsupported("help/version actions do not support negatable") + } + guard arg.env is None else { + raise Unsupported("help/version actions do not support env/defaults") + } + guard !arg.multiple else { + raise Unsupported("help/version actions do not support multiple values") + } + } + ctx.record_arg(arg) +} + +///| +fn validate_option_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + validate_named_option_arg(arg) + validate_default_values(arg) + ctx.record_arg(arg) +} + +///| +fn validate_positional_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + let (min, max) = arg_min_max_for_validate(arg) + if (min > 1 || (max is Some(m) && m > 1)) && !arg.multiple { + raise Unsupported( + "multiple values require action=Append or num_args allowing >1", + ) + } + validate_default_values(arg) + ctx.record_arg(arg) +} + +///| +fn validate_named_option_arg(arg : Arg) -> Unit raise ArgBuildError { + guard arg.info + is (FlagInfo(long~, short~, ..) | OptionInfo(long~, short~, ..)) + guard long is Some(_) || short is Some(_) || arg.env is Some(_) else { + raise Unsupported("flag/option args require short/long/env") + } +} + +///| +fn validate_default_values(arg : Arg) -> Unit raise ArgBuildError { + if arg.info + is (OptionInfo(default_values~, ..) | PositionalInfo(default_values~, ..)) && + default_values is Some(values) && + values.length() > 1 && + !arg.multiple && + !(arg.info is OptionInfo(action=Append, ..)) { + raise Unsupported( + "default_values with multiple entries require action=Append", + ) + } +} + +///| +fn validate_group_defs( + args : Array[Arg], + groups : Array[ArgGroup], +) -> Unit raise ArgBuildError { + let seen : @set.Set[String] = @set.new() + let arg_seen : @set.Set[String] = @set.new() + for arg in args { + arg_seen.add(arg.name) + } + for group in groups { + if !seen.add_and_check(group.name) { + raise Unsupported("duplicate group: \{group.name}") + } + } + for group in groups { + for required in group.requires { + if required == group.name { + raise Unsupported("group cannot require itself: \{group.name}") + } + if !seen.contains(required) && !arg_seen.contains(required) { + raise Unsupported( + "unknown group requires target: \{group.name} -> \{required}", + ) + } + } + for conflict in group.conflicts_with { + if conflict == group.name { + raise Unsupported("group cannot conflict with itself: \{group.name}") + } + if !seen.contains(conflict) && !arg_seen.contains(conflict) { + raise Unsupported( + "unknown group conflicts_with target: \{group.name} -> \{conflict}", + ) + } + } + } +} + +///| +fn validate_group_refs( + args : Array[Arg], + groups : Array[ArgGroup], +) -> Unit raise ArgBuildError { + if groups.length() == 0 { + return + } + let arg_index : @set.Set[String] = @set.new() + for arg in args { + arg_index.add(arg.name) + } + for group in groups { + for name in group.args { + if !arg_index.contains(name) { + raise Unsupported("unknown group arg: \{group.name} -> \{name}") + } + } + } +} + +///| +fn validate_requires_conflicts_targets( + args : Array[Arg], + seen_names : @set.Set[String], +) -> Unit raise ArgBuildError { + for arg in args { + for required in arg.requires { + if required == arg.name { + raise Unsupported("arg cannot require itself: \{arg.name}") + } + if !seen_names.contains(required) { + raise Unsupported("unknown requires target: \{arg.name} -> \{required}") + } + } + for conflict in arg.conflicts_with { + if conflict == arg.name { + raise Unsupported("arg cannot conflict with itself: \{arg.name}") + } + if !seen_names.contains(conflict) { + raise Unsupported( + "unknown conflicts_with target: \{arg.name} -> \{conflict}", + ) + } + } + } +} + +///| +fn validate_subcommand_defs(subs : Array[Command]) -> Unit raise ArgBuildError { + if subs.length() == 0 { + return + } + let seen : @set.Set[String] = @set.new() + for sub in subs { + if !seen.add_and_check(sub.name) { + raise Unsupported("duplicate subcommand: \{sub.name}") + } + } +} + +///| +fn validate_subcommand_required_policy( + cmd : Command, +) -> Unit raise ArgBuildError { + if cmd.subcommand_required && cmd.subcommands.length() == 0 { + raise Unsupported("subcommand_required requires at least one subcommand") + } +} + +///| +fn validate_help_subcommand(cmd : Command) -> Unit raise ArgBuildError { + if help_subcommand_enabled(cmd) && + cmd.subcommands.any(cmd => cmd.name == "help") { + raise Unsupported( + "subcommand name reserved for built-in help: help (disable with disable_help_subcommand)", + ) + } +} + +///| +fn validate_version_actions(cmd : Command) -> Unit raise ArgBuildError { + if cmd.version is None && + cmd.args.any(arg => arg.info is FlagInfo(action=Version, ..)) { + raise Unsupported("version action requires command version text") + } +} + +///| +fn validate_command_policies( + cmd : Command, + matches : Matches, +) -> Unit raise ArgParseError { + if cmd.subcommand_required && + cmd.subcommands.length() > 0 && + matches.parsed_subcommand is None { + raise MissingRequired("subcommand", None) + } +} + +///| +fn validate_groups( + args : Array[Arg], + groups : Array[ArgGroup], + matches : Matches, +) -> Unit raise ArgParseError { + if groups.length() == 0 { + return + } + let group_presence : Map[String, Int] = {} + let group_seen : @set.Set[String] = @set.new() + let arg_seen : @set.Set[String] = @set.new() + for group in groups { + group_seen.add(group.name) + } + for arg in args { + arg_seen.add(arg.name) + } + for group in groups { + let mut count = 0 + for arg in args { + if !arg_in_group(arg, group) { + continue + } + if matches_has_value_or_flag(matches, arg.name) { + count = count + 1 + } + } + group_presence[group.name] = count + if group.required && count == 0 { + raise MissingGroup(group.name) + } + if !group.multiple && count > 1 { + raise GroupConflict(group.name) + } + } + for group in groups { + let count = group_presence[group.name] + if count == 0 { + continue + } + for required in group.requires { + if group_seen.contains(required) { + if group_presence.get(required).unwrap_or(0) == 0 { + raise MissingGroup(required) + } + } else if arg_seen.contains(required) { + if !matches_has_value_or_flag(matches, required) { + raise MissingRequired(required, None) + } + } + } + for conflict in group.conflicts_with { + if group_seen.contains(conflict) { + if group_presence.get(conflict).unwrap_or(0) > 0 { + raise GroupConflict("\{group.name} conflicts with \{conflict}") + } + } else if arg_seen.contains(conflict) { + if matches_has_value_or_flag(matches, conflict) { + raise GroupConflict("\{group.name} conflicts with \{conflict}") + } + } + } + } +} + +///| +fn arg_in_group(arg : Arg, group : ArgGroup) -> Bool { + group.args.contains(arg.name) +} + +///| +fn validate_values( + args : Array[Arg], + matches : Matches, +) -> Unit raise ArgParseError { + for arg in args { + let present = matches_has_value_or_flag(matches, arg.name) + if arg.required && !present { + raise MissingRequired(arg.name, None) + } + guard arg.info is (OptionInfo(_) | PositionalInfo(_)) else { continue } + if !present { + if arg.info is PositionalInfo(_) { + let (min, _) = arg_min_max(arg) + if min > 0 { + raise TooFewValues(arg.name, 0, min) + } + } + continue + } + let values = matches.values.get(arg.name).unwrap_or([]) + let count = values.length() + let (min, max) = arg_min_max(arg) + if count < min { + raise TooFewValues(arg.name, count, min) + } + if !(arg.info is OptionInfo(action=Append, ..)) { + match max { + Some(max) if count > max => raise TooManyValues(arg.name, count, max) + _ => () + } + } + } +} + +///| +fn validate_relationships( + matches : Matches, + args : Array[Arg], +) -> Unit raise ArgParseError { + for arg in args { + if !matches_has_value_or_flag(matches, arg.name) { + continue + } + for required in arg.requires { + if !matches_has_value_or_flag(matches, required) { + raise MissingRequired(required, Some(arg.name)) + } + } + for conflict in arg.conflicts_with { + if matches_has_value_or_flag(matches, conflict) { + raise InvalidArgument( + "conflicting arguments: \{arg.name} and \{conflict}", + ) + } + } + } +} diff --git a/argparse/parser_values.mbt b/argparse/parser_values.mbt new file mode 100644 index 000000000..f45a831d9 --- /dev/null +++ b/argparse/parser_values.mbt @@ -0,0 +1,319 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn assign_positionals( + matches : Matches, + positionals : Array[Arg], + values : Array[String], +) -> Unit raise ArgParseError { + let mut cursor = 0 + for idx in 0.. max_count => take = max_count + _ => () + } + if take < min { + take = min + } + if take > remaining { + take = remaining + } + let mut taken = 0 + while taken < take { + add_value(matches, arg.name, values[cursor + taken], arg, Argv) + taken = taken + 1 + } + cursor = cursor + taken + continue + } + if remaining > 0 { + add_value(matches, arg.name, values[cursor], arg, Argv) + cursor = cursor + 1 + } + } + if cursor < values.length() { + let overflow_label = if positionals.length() > 0 { + let last = positionals[positionals.length() - 1] + if last.multiple { + Some("<\{last.name}...>") + } else { + Some("<\{last.name}>") + } + } else { + None + } + raise TooManyPositionals(values[cursor], overflow_label) + } +} + +///| +fn positional_min_required(arg : Arg) -> Int { + let (min, _) = arg_min_max(arg) + if min > 0 { + min + } else { + 0 + } +} + +///| +fn remaining_positional_min(positionals : Array[Arg], start : Int) -> Int { + let mut total = 0 + let mut idx = start + while idx < positionals.length() { + total = total + positional_min_required(positionals[idx]) + idx = idx + 1 + } + total +} + +///| +fn add_value( + matches : Matches, + name : String, + value : String, + arg : Arg, + source : ValueSource, +) -> Unit { + if arg.multiple || arg.info is OptionInfo(action=Append, ..) { + let arr = matches.values.get(name).unwrap_or([]) + arr.push(value) + matches.values[name] = arr + matches.value_sources[name] = source + } else { + matches.values[name] = [value] + matches.value_sources[name] = source + } +} + +///| +fn assign_value( + matches : Matches, + arg : Arg, + value : String, + source : ValueSource, +) -> Unit raise ArgParseError { + match arg.info { + OptionInfo(action=Append, ..) => + add_value(matches, arg.name, value, arg, source) + OptionInfo(action=Set, ..) | PositionalInfo(_) => + add_value(matches, arg.name, value, arg, source) + FlagInfo(action=SetTrue, ..) => { + let flag = parse_bool(value) + matches.flags[arg.name] = flag + matches.flag_sources[arg.name] = source + } + FlagInfo(action=SetFalse, ..) => { + let flag = parse_bool(value) + matches.flags[arg.name] = !flag + matches.flag_sources[arg.name] = source + } + FlagInfo(action=Count, ..) => { + let count = parse_count(value) + matches.counts[arg.name] = count + matches.flags[arg.name] = count > 0 + matches.flag_sources[arg.name] = source + } + FlagInfo(action=Help, ..) => + raise InvalidArgument("help action does not take values") + FlagInfo(action=Version, ..) => + raise InvalidArgument("version action does not take values") + } +} + +///| +fn option_conflict_label(arg : Arg) -> String { + match arg.info { + FlagInfo(long~, short~, ..) | OptionInfo(long~, short~, ..) => + match long { + Some(name) => "--\{name}" + None => + match short { + Some(short) => "-\{short}" + None => arg.name + } + } + PositionalInfo(_) => arg.name + } +} + +///| +fn check_duplicate_set_occurrence( + matches : Matches, + arg : Arg, +) -> Unit raise ArgParseError { + guard arg.info is (OptionInfo(action=Set, ..) | PositionalInfo(_)) else { + return + } + if matches.values.get(arg.name) is Some(values) && values.length() > 0 { + raise InvalidArgument( + "argument '\{option_conflict_label(arg)}' cannot be used multiple times", + ) + } +} + +///| +fn should_stop_option_value( + value : String, + arg : Arg, + _long_index : Map[String, Arg], + _short_index : Map[Char, Arg], +) -> Bool { + if !value.has_prefix("-") || value == "-" { + return false + } + if arg.info + is (OptionInfo(allow_hyphen_values~, ..) + | PositionalInfo(allow_hyphen_values~, ..)) && + allow_hyphen_values { + // Rust clap parity: + // - `clap_builder/src/parser/parser.rs`: `parse_long_arg` / `parse_short_arg` + // return `ParseResult::MaybeHyphenValue` when the pending arg in + // `ParseState::Opt` or `ParseState::Pos` has `allow_hyphen_values`. + // - `clap_builder/src/builder/arg.rs` (`Arg::allow_hyphen_values` docs): + // prior args with this setting take precedence over known flags/options. + // - `tests/builder/opts.rs` (`leading_hyphen_with_flag_after`): + // a pending option consumes `-f` as a value rather than parsing flag `-f`. + // This also means `--` is consumed as a value while the option remains pending. + return false + } + true +} + +///| +fn apply_env( + matches : Matches, + args : Array[Arg], + env : Map[String, String], +) -> Unit raise ArgParseError { + for arg in args { + let name = arg.name + if matches_has_value_or_flag(matches, name) { + continue + } + let env_name = match arg.env { + Some(value) => value + None => continue + } + let value = match env.get(env_name) { + Some(v) => v + None => continue + } + if arg.info is (OptionInfo(_) | PositionalInfo(_)) { + assign_value(matches, arg, value, Env) + continue + } + match arg.info { + FlagInfo(action=Count, ..) => { + let count = parse_count(value) + matches.counts[name] = count + matches.flags[name] = count > 0 + matches.flag_sources[name] = Env + } + FlagInfo(action=SetFalse, ..) => { + let flag = parse_bool(value) + matches.flags[name] = !flag + matches.flag_sources[name] = Env + } + FlagInfo(action=SetTrue, ..) => { + let flag = parse_bool(value) + matches.flags[name] = flag + matches.flag_sources[name] = Env + } + OptionInfo(_) | PositionalInfo(_) => () + FlagInfo(action=Help | Version, ..) => () + } + } +} + +///| +fn apply_defaults(matches : Matches, args : Array[Arg]) -> Unit { + for arg in args { + let default_values = match arg.info { + FlagInfo(_) => continue + OptionInfo(default_values~, ..) | PositionalInfo(default_values~, ..) => + default_values + } + if matches_has_value_or_flag(matches, arg.name) { + continue + } + match default_values { + Some(values) if values.length() > 0 => + for value in values { + let _ = add_value(matches, arg.name, value, arg, Default) + } + _ => () + } + } +} + +///| +fn matches_has_value_or_flag(matches : Matches, name : String) -> Bool { + matches.flags.contains(name) || matches.values.contains(name) +} + +///| +fn apply_flag(matches : Matches, arg : Arg, source : ValueSource) -> Unit { + match arg.info { + FlagInfo(action=SetTrue, ..) => matches.flags[arg.name] = true + FlagInfo(action=SetFalse, ..) => matches.flags[arg.name] = false + FlagInfo(action=Count, ..) => { + let current = matches.counts.get(arg.name).unwrap_or(0) + matches.counts[arg.name] = current + 1 + matches.flags[arg.name] = true + } + FlagInfo(action=Help, ..) => () + FlagInfo(action=Version, ..) => () + _ => matches.flags[arg.name] = true + } + matches.flag_sources[arg.name] = source +} + +///| +fn parse_bool(value : String) -> Bool raise ArgParseError { + match value { + "1" | "true" | "yes" | "on" => true + "0" | "false" | "no" | "off" => false + _ => + raise InvalidValue( + "invalid value '\{value}' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off", + ) + } +} + +///| +fn parse_count(value : String) -> Int raise ArgParseError { + try @strconv.parse_int(value) catch { + _ => + raise InvalidValue( + "invalid value '\{value}' for count; expected a non-negative integer", + ) + } noraise { + _..<0 => + raise InvalidValue( + "invalid value '\{value}' for count; expected a non-negative integer", + ) + v => v + } +} diff --git a/argparse/pkg.generated.mbti b/argparse/pkg.generated.mbti new file mode 100644 index 000000000..431e79037 --- /dev/null +++ b/argparse/pkg.generated.mbti @@ -0,0 +1,100 @@ +// Generated using `moon info`, DON'T EDIT IT +package "moonbitlang/core/argparse" + +import { + "moonbitlang/core/debug", +} + +// Values + +// Errors + +// Types and methods +pub struct ArgGroup { + // private fields + + fn new(StringView, required? : Bool, multiple? : Bool, args? : ArrayView[String], requires? : ArrayView[String], conflicts_with? : ArrayView[String]) -> ArgGroup +} +pub fn ArgGroup::new(StringView, required? : Bool, multiple? : Bool, args? : ArrayView[String], requires? : ArrayView[String], conflicts_with? : ArrayView[String]) -> Self + +pub struct Command { + // private fields + + fn new(StringView, flags? : ArrayView[FlagArg], options? : ArrayView[OptionArg], positionals? : ArrayView[PositionArg], subcommands? : ArrayView[Command], about? : StringView, version? : StringView, disable_help_flag? : Bool, disable_version_flag? : Bool, disable_help_subcommand? : Bool, arg_required_else_help? : Bool, subcommand_required? : Bool, hidden? : Bool, groups? : ArrayView[ArgGroup]) -> Command +} +pub fn Command::new(StringView, flags? : ArrayView[FlagArg], options? : ArrayView[OptionArg], positionals? : ArrayView[PositionArg], subcommands? : ArrayView[Self], about? : StringView, version? : StringView, disable_help_flag? : Bool, disable_version_flag? : Bool, disable_help_subcommand? : Bool, arg_required_else_help? : Bool, subcommand_required? : Bool, hidden? : Bool, groups? : ArrayView[ArgGroup]) -> Self +#as_free_fn +pub fn Command::parse(Self, argv? : ArrayView[String], env? : Map[String, String]) -> Matches raise +pub fn Command::render_help(Self) -> String + +pub(all) enum FlagAction { + SetTrue + SetFalse + Count + Help + Version +} +pub impl Eq for FlagAction +pub impl Show for FlagAction + +pub struct FlagArg { + // private fields + + fn new(StringView, short? : Char, long? : StringView, about? : StringView, action? : FlagAction, env? : StringView, requires? : ArrayView[String], conflicts_with? : ArrayView[String], required? : Bool, global? : Bool, negatable? : Bool, hidden? : Bool) -> FlagArg +} +pub fn FlagArg::new(StringView, short? : Char, long? : StringView, about? : StringView, action? : FlagAction, env? : StringView, requires? : ArrayView[String], conflicts_with? : ArrayView[String], required? : Bool, global? : Bool, negatable? : Bool, hidden? : Bool) -> Self + +pub struct Matches { + flags : Map[String, Bool] + values : Map[String, Array[String]] + flag_counts : Map[String, Int] + sources : Map[String, ValueSource] + subcommand : (String, Matches)? + // private fields +} +pub impl @debug.Debug for Matches + +pub(all) enum OptionAction { + Set + Append +} +pub impl Eq for OptionAction +pub impl Show for OptionAction + +pub struct OptionArg { + // private fields + + fn new(StringView, short? : Char, long? : StringView, about? : StringView, action? : OptionAction, env? : StringView, default_values? : ArrayView[String], allow_hyphen_values? : Bool, requires? : ArrayView[String], conflicts_with? : ArrayView[String], required? : Bool, global? : Bool, hidden? : Bool) -> OptionArg +} +pub fn OptionArg::new(StringView, short? : Char, long? : StringView, about? : StringView, action? : OptionAction, env? : StringView, default_values? : ArrayView[String], allow_hyphen_values? : Bool, requires? : ArrayView[String], conflicts_with? : ArrayView[String], required? : Bool, global? : Bool, hidden? : Bool) -> Self + +pub struct PositionArg { + // private fields + + fn new(StringView, about? : StringView, env? : StringView, default_values? : ArrayView[String], num_args? : ValueRange, allow_hyphen_values? : Bool, requires? : ArrayView[String], conflicts_with? : ArrayView[String], global? : Bool, hidden? : Bool) -> PositionArg +} +pub fn PositionArg::new(StringView, about? : StringView, env? : StringView, default_values? : ArrayView[String], num_args? : ValueRange, allow_hyphen_values? : Bool, requires? : ArrayView[String], conflicts_with? : ArrayView[String], global? : Bool, hidden? : Bool) -> Self + +pub struct ValueRange { + // private fields + + fn new(lower? : Int, upper? : Int) -> ValueRange +} +pub fn ValueRange::new(lower? : Int, upper? : Int) -> Self +pub fn ValueRange::single() -> Self +pub impl Eq for ValueRange +pub impl Show for ValueRange + +pub enum ValueSource { + Argv + Env + Default +} +pub impl Eq for ValueSource +pub impl Show for ValueSource +pub impl @debug.Debug for ValueSource + +// Type aliases + +// Traits + diff --git a/argparse/runtime_exit.mbt b/argparse/runtime_exit.mbt new file mode 100644 index 000000000..0c83dc599 --- /dev/null +++ b/argparse/runtime_exit.mbt @@ -0,0 +1,28 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +fn trim_single_trailing_newline(text : String) -> String { + match text.strip_suffix("\n") { + Some(view) => view.to_string() + None => text + } +} + +///| +fn[T] print_and_exit_success(text : String) -> T { + println(trim_single_trailing_newline(text)) + runtime_exit_success() + panic() +} diff --git a/argparse/runtime_exit_js.mbt b/argparse/runtime_exit_js.mbt new file mode 100644 index 000000000..ee312a997 --- /dev/null +++ b/argparse/runtime_exit_js.mbt @@ -0,0 +1,32 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +#cfg(target="js") +extern "js" fn runtime_js_try_exit(code : Int) -> Bool = + #| function(code) { + #| if (typeof process !== "undefined" && typeof process.exit === "function") { + #| process.exit(code); + #| return true; + #| } + #| return false; + #| } + +///| +#cfg(target="js") +fn runtime_exit_success() -> Unit { + if !runtime_js_try_exit(0) { + panic() + } +} diff --git a/argparse/runtime_exit_native.mbt b/argparse/runtime_exit_native.mbt new file mode 100644 index 000000000..86a6648e8 --- /dev/null +++ b/argparse/runtime_exit_native.mbt @@ -0,0 +1,23 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +#cfg(target="native") +extern "c" fn runtime_native_exit(code : Int) = "exit" + +///| +#cfg(target="native") +fn runtime_exit_success() -> Unit { + runtime_native_exit(0) +} diff --git a/argparse/runtime_exit_wasm.mbt b/argparse/runtime_exit_wasm.mbt new file mode 100644 index 000000000..2fe21b878 --- /dev/null +++ b/argparse/runtime_exit_wasm.mbt @@ -0,0 +1,23 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +#cfg(any(target="wasm", target="wasm-gc")) +fn runtime_wasm_exit(code : Int) -> Unit = "__moonbit_sys_unstable" "exit" + +///| +#cfg(any(target="wasm", target="wasm-gc")) +fn runtime_exit_success() -> Unit { + runtime_wasm_exit(0) +} diff --git a/argparse/value_range.mbt b/argparse/value_range.mbt new file mode 100644 index 000000000..2f62402d4 --- /dev/null +++ b/argparse/value_range.mbt @@ -0,0 +1,41 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +/// Number-of-values constraint for an argument. +pub struct ValueRange { + priv lower : Int + priv upper : Int? + + /// Create a value-count range. + fn new(lower? : Int, upper? : Int) -> ValueRange +} derive(Eq, Show) + +///| +/// Exact single-value range (`1..1`). +pub fn ValueRange::single() -> ValueRange { + ValueRange(lower=1, upper=1) +} + +///| +/// Create a value-count range. +/// +/// Notes: +/// - `lower` defaults to `0`. +/// - `upper` omitted means no upper bound. +/// - `ValueRange(lower=0)` means `0..`. +/// - `ValueRange(lower=1, upper=3)` means `1..=3`. +pub fn ValueRange::new(lower? : Int = 0, upper? : Int) -> ValueRange { + { lower, upper } +}