diff --git a/argparse/README.mbt.md b/argparse/README.mbt.md new file mode 100644 index 000000000..bc895e2b8 --- /dev/null +++ b/argparse/README.mbt.md @@ -0,0 +1,143 @@ +# moonbitlang/core/argparse + +Declarative argument parsing for MoonBit. + +This package is inspired by [`clap`](https://github.com/clap-rs/clap) and intentionally implements a small, +predictable subset of its behavior. + +## Positional Semantics + +Positional behavior is deterministic and intentionally strict: + +- `index` is zero-based. +- Indexed positionals are ordered by ascending `index`. +- Unindexed positionals are appended after indexed ones in declaration order. +- For indexed positionals that are not last, `num_args` must be omitted or exactly + `ValueRange::single()` (`1..1`). +- If a positional has `num_args.lower > 0` and no value is provided, parsing raises + `ArgParseError::TooFewValues`. + +## Argument Shape Rule + +`FlagArg` and `OptionArg` must provide at least one of `short` or `long`. +Arguments without both are positional-only and should be declared with +`PositionalArg`. + +```mbt check +///| +test "name-only option is rejected" { + let cmd = @argparse.Command("demo", args=[@argparse.OptionArg("input")]) + try cmd.parse(argv=["file.txt"], env={}) catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="flag/option args require short/long") + _ => panic() + } noraise { + _ => panic() + } +} +``` + +## Core Patterns + +```mbt check +///| +test "flag option positional" { + let cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg("verbose", short='v', long="verbose"), + @argparse.OptionArg("count", long="count"), + @argparse.PositionalArg("name", index=0), + ]) + let matches = cmd.parse(argv=["-v", "--count", "2", "alice"], env={}) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["2"], "name": ["alice"], .. }) +} + +///| +test "subcommand with global flag" { + let echo = @argparse.Command("echo", args=[ + @argparse.PositionalArg("msg", index=0), + ]) + let cmd = @argparse.Command( + "demo", + args=[@argparse.FlagArg("verbose", short='v', long="verbose", global=true)], + subcommands=[echo], + ) + let matches = cmd.parse(argv=["--verbose", "echo", "hi"], env={}) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true( + matches.subcommand is Some(("echo", sub)) && + sub.flags is { "verbose": true, .. } && + sub.values is { "msg": ["hi"], .. }, + ) +} +``` + +## Help and Version Snapshots + +`parse` raises display events instead of exiting. Snapshot tests work well for +help text: + +```mbt check +///| +test "help snapshot" { + let cmd = @argparse.Command("demo", about="demo app", version="1.0.0", args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + about="verbose mode", + ), + @argparse.OptionArg("count", long="count", about="repeat count"), + ]) + try cmd.parse(argv=["--help"], env={}) catch { + @argparse.DisplayHelp::Message(text) => + inspect( + text, + content=( + #|Usage: demo [options] + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| -v, --verbose verbose mode + #| --count repeat count + #| + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "custom version option overrides built-in version flag" { + let cmd = @argparse.Command("demo", version="1.0.0", args=[ + @argparse.FlagArg( + "custom_version", + short='V', + long="version", + about="custom version flag", + ), + ]) + let matches = cmd.parse(argv=["--version"], env={}) catch { _ => panic() } + assert_true(matches.flags is { "custom_version": true, .. }) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version custom version flag + #| + ), + ) +} +``` diff --git a/argparse/arg_action.mbt b/argparse/arg_action.mbt new file mode 100644 index 000000000..3f09e86e2 --- /dev/null +++ b/argparse/arg_action.mbt @@ -0,0 +1,81 @@ +// 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. + +///| +/// Parser-internal action model used for control flow. +priv enum ArgAction { + Set + SetTrue + SetFalse + Count + Append + Help + Version +} derive(Eq) + +///| +fn arg_takes_value(arg : Arg) -> Bool { + !arg.is_flag +} + +///| +fn arg_action(arg : Arg) -> ArgAction { + if arg.is_flag { + match arg.flag_action { + FlagAction::SetTrue => ArgAction::SetTrue + FlagAction::SetFalse => ArgAction::SetFalse + FlagAction::Count => ArgAction::Count + FlagAction::Help => ArgAction::Help + FlagAction::Version => ArgAction::Version + } + } else { + match arg.option_action { + OptionAction::Set => ArgAction::Set + OptionAction::Append => ArgAction::Append + } + } +} + +///| +fn arg_min_max_for_validate(arg : Arg) -> (Int, Int?) raise ArgBuildError { + if arg.num_args is Some(range) { + if range.lower < 0 { + raise ArgBuildError::Unsupported("min values must be >= 0") + } + if range.upper is Some(max_value) { + if max_value < 0 { + raise ArgBuildError::Unsupported("max values must be >= 0") + } + if max_value < range.lower { + raise ArgBuildError::Unsupported("max values must be >= min values") + } + if range.lower == 0 && max_value == 0 { + raise ArgBuildError::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.num_args { + Some(range) => (range.lower, range.upper) + None => (0, None) + } +} diff --git a/argparse/arg_group.mbt b/argparse/arg_group.mbt new file mode 100644 index 000000000..5147ae5fd --- /dev/null +++ b/argparse/arg_group.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. + +///| +/// 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 : String, + required? : Bool, + multiple? : Bool, + args? : Array[String], + requires? : Array[String], + conflicts_with? : Array[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` can reference either group names or arg names. +pub fn ArgGroup::new( + name : String, + required? : Bool = false, + multiple? : Bool = true, + args? : Array[String] = [], + requires? : Array[String] = [], + conflicts_with? : Array[String] = [], +) -> ArgGroup { + ArgGroup::{ + name, + required, + multiple, + args: args.copy(), + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), + } +} diff --git a/argparse/arg_spec.mbt b/argparse/arg_spec.mbt new file mode 100644 index 000000000..c00949976 --- /dev/null +++ b/argparse/arg_spec.mbt @@ -0,0 +1,349 @@ +// 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. +pub(all) enum FlagAction { + SetTrue + SetFalse + Count + Help + Version +} derive(Eq, Show) + +///| +/// Behavior for option args. +pub(all) enum OptionAction { + Set + Append +} derive(Eq, Show) + +///| +/// Unified argument model used by the parser internals. +priv struct Arg { + name : String + short : Char? + long : String? + index : Int? + about : String? + is_flag : Bool + is_positional : Bool + flag_action : FlagAction + option_action : OptionAction + env : String? + default_values : Array[String]? + num_args : ValueRange? + multiple : Bool + allow_hyphen_values : Bool + last : Bool + requires : Array[String] + conflicts_with : Array[String] + required : Bool + global : Bool + negatable : Bool + hidden : Bool +} + +///| +/// Trait for declarative arg constructors. +trait ArgLike { + to_arg(Self) -> Arg + validate(Self, ValidationCtx) -> Unit raise ArgBuildError +} + +///| +/// Declarative flag constructor wrapper. +pub struct FlagArg { + priv arg : Arg + + /// Create a flag argument. + fn new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : FlagAction, + env? : String, + requires? : Array[String], + conflicts_with? : Array[String], + required? : Bool, + global? : Bool, + negatable? : Bool, + hidden? : Bool, + ) -> FlagArg +} + +///| +pub impl ArgLike for FlagArg with to_arg(self : FlagArg) { + self.arg +} + +///| +pub impl ArgLike for FlagArg with validate(self, ctx) { + validate_flag_arg(self.arg, ctx) +} + +///| +/// Create a flag argument. +/// +/// At least one of `short` or `long` must be provided. +/// +/// `global=true` makes the flag available in subcommands. +/// +/// If `negatable=true`, `--no-` is accepted for long flags. +pub fn FlagArg::new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : FlagAction = FlagAction::SetTrue, + env? : String, + requires? : Array[String] = [], + conflicts_with? : Array[String] = [], + required? : Bool = false, + global? : Bool = false, + negatable? : Bool = false, + hidden? : Bool = false, +) -> FlagArg { + FlagArg::{ + arg: Arg::{ + name, + short, + long, + index: None, + about, + is_flag: true, + is_positional: false, + flag_action: action, + option_action: OptionAction::Set, + env, + default_values: None, + num_args: None, + multiple: false, + allow_hyphen_values: false, + last: false, + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), + required, + global, + negatable, + hidden, + }, + } +} + +///| +/// Declarative option constructor wrapper. +pub struct OptionArg { + priv arg : Arg + + /// Create an option argument. + fn new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : OptionAction, + env? : String, + default_values? : Array[String], + allow_hyphen_values? : Bool, + last? : Bool, + requires? : Array[String], + conflicts_with? : Array[String], + required? : Bool, + global? : Bool, + hidden? : Bool, + ) -> OptionArg +} + +///| +pub impl ArgLike for OptionArg with to_arg(self : OptionArg) { + self.arg +} + +///| +pub impl ArgLike for OptionArg with validate(self, ctx) { + validate_option_arg(self.arg, ctx) +} + +///| +/// Create an option argument that consumes one value per occurrence. +/// +/// At least one of `short` or `long` must be provided. +/// +/// Use `action=Append` for repeated occurrences. +/// +/// `global=true` makes the option available in subcommands. +pub fn OptionArg::new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : OptionAction = OptionAction::Set, + env? : String, + default_values? : Array[String], + allow_hyphen_values? : Bool = false, + last? : Bool = false, + requires? : Array[String] = [], + conflicts_with? : Array[String] = [], + required? : Bool = false, + global? : Bool = false, + hidden? : Bool = false, +) -> OptionArg { + OptionArg::{ + arg: Arg::{ + name, + short, + long, + index: None, + about, + is_flag: false, + is_positional: false, + flag_action: FlagAction::SetTrue, + option_action: action, + env, + default_values: default_values.map(Array::copy), + num_args: None, + multiple: allows_multiple_values(action), + allow_hyphen_values, + last, + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), + required, + global, + negatable: false, + hidden, + }, + } +} + +///| +/// Declarative positional constructor wrapper. +pub struct PositionalArg { + priv arg : Arg + + /// Create a positional argument. + fn new( + name : String, + index? : Int, + about? : String, + env? : String, + default_values? : Array[String], + num_args? : ValueRange, + allow_hyphen_values? : Bool, + last? : Bool, + requires? : Array[String], + conflicts_with? : Array[String], + required? : Bool, + global? : Bool, + hidden? : Bool, + ) -> PositionalArg +} + +///| +pub impl ArgLike for PositionalArg with to_arg(self : PositionalArg) { + self.arg +} + +///| +pub impl ArgLike for PositionalArg with validate(self, ctx) { + validate_positional_arg(self.arg, ctx) +} + +///| +/// Create a positional argument. +/// +/// Positional ordering: +/// - `index` is zero-based. +/// - Indexed positionals are sorted by `index`. +/// - Unindexed positionals are appended after indexed ones in declaration order. +/// +/// `num_args` controls the accepted value count. +/// +/// For indexed positionals that are not the last positional, `num_args` must be +/// omitted or exactly `ValueRange::single()` (`1..1`); other ranges are rejected +/// at build time. +pub fn PositionalArg::new( + name : String, + index? : Int, + about? : String, + env? : String, + default_values? : Array[String], + num_args? : ValueRange, + allow_hyphen_values? : Bool = false, + last? : Bool = false, + requires? : Array[String] = [], + conflicts_with? : Array[String] = [], + required? : Bool = false, + global? : Bool = false, + hidden? : Bool = false, +) -> PositionalArg { + PositionalArg::{ + arg: Arg::{ + name, + short: None, + long: None, + index, + about, + is_flag: false, + is_positional: true, + flag_action: FlagAction::SetTrue, + option_action: OptionAction::Set, + env, + default_values: default_values.map(Array::copy), + num_args, + multiple: range_allows_multiple(num_args), + allow_hyphen_values, + last, + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), + required, + global, + negatable: false, + hidden, + }, + } +} + +///| +fn arg_name(arg : Arg) -> String { + arg.name +} + +///| +fn is_flag_spec(arg : Arg) -> Bool { + arg.is_flag +} + +///| +fn is_count_flag_spec(arg : Arg) -> Bool { + arg.is_flag && arg.flag_action == FlagAction::Count +} + +///| +fn allows_multiple_values(action : OptionAction) -> Bool { + action == OptionAction::Append +} + +///| +fn range_allows_multiple(range : ValueRange?) -> Bool { + match range { + Some(r) => + match r.upper { + Some(upper) => r.lower != upper || r.lower > 1 + None => true + } + None => false + } +} diff --git a/argparse/argparse_blackbox_test.mbt b/argparse/argparse_blackbox_test.mbt new file mode 100644 index 000000000..262bbd422 --- /dev/null +++ b/argparse/argparse_blackbox_test.mbt @@ -0,0 +1,2446 @@ +// 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. + +///| +struct DecodeName { + name : String +} + +///| +impl @argparse.FromMatches for DecodeName with from_matches( + matches : @argparse.Matches, +) { + match matches.values.get("name") { + Some(values) if values.length() > 0 => DecodeName::{ name: values[0] } + _ => raise @argparse.ArgParseError::MissingRequired("name") + } +} + +///| +test "render help snapshot with groups and hidden entries" { + let cmd = @argparse.Command( + "render", + groups=[ + @argparse.ArgGroup("mode", required=true, multiple=false, args=[ + "fast", "slow", "path", + ]), + ], + subcommands=[ + @argparse.Command("run", about="run"), + @argparse.Command("hidden", about="hidden", hidden=true), + ], + args=[ + @argparse.FlagArg("fast", short='f', long="fast"), + @argparse.FlagArg("slow", long="slow", hidden=true), + @argparse.FlagArg("cache", long="cache", negatable=true, about="cache"), + @argparse.OptionArg( + "path", + short='p', + long="path", + env="PATH_ENV", + default_values=["a", "b"], + required=true, + ), + @argparse.PositionalArg("target", index=0, required=true), + @argparse.PositionalArg( + "rest", + index=1, + num_args=@argparse.ValueRange(lower=0), + ), + @argparse.PositionalArg("secret", index=2, hidden=true), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: render [options] [rest...] [command] + #| + #|Commands: + #| run run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| target required + #| rest... + #| + #|Options: + #| -h, --help Show help information. + #| -f, --fast + #| --[no-]cache cache + #| -p, --path env: PATH_ENV, defaults: a, b, required + #| + #|Groups: + #| mode (required, exclusive) -f, --fast, -p, --path + #| + ), + ) +} + +///| +test "render help conversion coverage snapshot" { + let cmd = @argparse.Command( + "shape", + groups=[@argparse.ArgGroup("grp", args=["f", "opt", "pos"])], + args=[ + @argparse.FlagArg( + "f", + short='f', + about="f", + env="F_ENV", + requires=["opt"], + required=true, + global=true, + hidden=true, + ), + @argparse.OptionArg( + "opt", + short='o', + about="opt", + default_values=["x", "y"], + env="OPT_ENV", + allow_hyphen_values=true, + last=true, + required=true, + global=true, + hidden=true, + conflicts_with=["pos"], + ), + @argparse.PositionalArg( + "pos", + about="pos", + env="POS_ENV", + default_values=["p1", "p2"], + num_args=@argparse.ValueRange(lower=0, upper=2), + allow_hyphen_values=true, + last=true, + requires=["opt"], + conflicts_with=["f"], + required=true, + 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", args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + action=@argparse.FlagAction::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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "global option merges parent and child values" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.OptionArg( + "profile", + short='p', + long="profile", + action=@argparse.OptionAction::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": @argparse.ValueSource::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", + args=[ + @argparse.OptionArg("mode", long="mode", requires=["config"], global=true), + @argparse.OptionArg("config", long="config", global=true), + ], + subcommands=[@argparse.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", + args=[ + @argparse.OptionArg( + "profile", + long="profile", + action=@argparse.OptionAction::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": @argparse.ValueSource::Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": @argparse.ValueSource::Argv, .. }, + ) +} + +///| +test "global scalar keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.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": @argparse.ValueSource::Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": @argparse.ValueSource::Argv, .. }, + ) +} + +///| +test "global count merges parent and child occurrences" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + action=@argparse.FlagAction::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", + args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + action=@argparse.FlagAction::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": @argparse.ValueSource::Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": @argparse.ValueSource::Argv, .. }, + ) +} + +///| +test "global flag keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.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": @argparse.ValueSource::Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": @argparse.ValueSource::Argv, .. }, + ) +} + +///| +test "subcommand cannot follow positional arguments" { + let cmd = @argparse.Command( + "demo", + args=[@argparse.PositionalArg("input", index=0)], + subcommands=[@argparse.Command("run")], + ) + try cmd.parse(argv=["raw", "run"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(msg) => + inspect( + msg, + content=( + #|subcommand 'run' cannot be used with positional arguments + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "global count source keeps env across subcommand merge" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + action=@argparse.FlagAction::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": @argparse.ValueSource::Env, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": @argparse.ValueSource::Env, .. }, + ) +} + +///| +test "help subcommand styles and errors" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command("demo", subcommands=[leaf]) + + try cmd.parse(argv=["help", "echo", "-h"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + inspect( + text, + content=( + #|Usage: echo + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "echo", "--help"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + inspect( + text, + content=( + #|Usage: echo + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + inspect( + text, + content=( + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "--bad"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(msg) => + inspect( + msg, + content=( + #|unexpected help argument: --bad + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "missing"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(msg) => + inspect( + msg, + content=( + #|unknown subcommand: missing + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "subcommand help includes inherited global options" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + about="Enable verbose mode", + global=true, + ), + ], + subcommands=[leaf], + ) + + try cmd.parse(argv=["echo", "-h"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => { + assert_true(text.contains("Usage: echo [options]")) + assert_true(text.contains("-v, --verbose")) + } + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "echo"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => { + assert_true(text.contains("Usage: echo [options]")) + assert_true(text.contains("-v, --verbose")) + } + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "unknown argument suggestions are exposed" { + let cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg("verbose", short='v', long="verbose"), + ]) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + @argparse.ArgParseError::UnknownArgument(arg, hint) => { + assert_true(arg == "--verbse") + assert_true(hint is Some("--verbose")) + } + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-x"], env=empty_env()) catch { + @argparse.ArgParseError::UnknownArgument(arg, hint) => { + assert_true(arg == "-x") + assert_true(hint is Some("-v")) + } + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--zzzzzzzzzz"], env=empty_env()) catch { + @argparse.ArgParseError::UnknownArgument(arg, hint) => { + assert_true(arg == "--zzzzzzzzzz") + assert_true(hint is None) + } + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "long and short value parsing branches" { + let cmd = @argparse.Command("demo", args=[ + @argparse.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 { + @argparse.ArgParseError::MissingValue(name) => + assert_true(name == "--count") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-c"], env=empty_env()) catch { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "-c") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "append option action is publicly selectable" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "negation parsing and invalid negation forms" { + let cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg("cache", long="cache", negatable=true), + @argparse.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": @argparse.ValueSource::Argv, .. }) + + try cmd.parse(argv=["--no-path"], env=empty_env()) catch { + @argparse.ArgParseError::UnknownArgument(arg, _) => + assert_true(arg == "--no-path") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--no-missing"], env=empty_env()) catch { + @argparse.ArgParseError::UnknownArgument(arg, _) => + assert_true(arg == "--no-missing") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--no-cache=1"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(arg) => + assert_true(arg == "--no-cache=1") + _ => panic() + } noraise { + _ => panic() + } + + let count_cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg( + "verbose", + long="verbose", + action=@argparse.FlagAction::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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "positionals force mode and dash handling" { + let force_cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "tail", + index=0, + num_args=@argparse.ValueRange(lower=0), + last=true, + 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", args=[ + @argparse.PositionalArg("n", index=0), + ]) + 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 { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "variadic positional keeps accepting hyphen values after first token" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "tail", + index=0, + num_args=@argparse.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", args=[ + @argparse.PositionalArg( + "first", + num_args=@argparse.ValueRange(lower=1, upper=2), + ), + @argparse.PositionalArg("second", required=true), + ]) + + 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", args=[ + @argparse.PositionalArg( + "first", + index=0, + num_args=@argparse.ValueRange::single(), + ), + @argparse.PositionalArg("second", index=1, required=true), + ]) + + 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", args=[ + @argparse.PositionalArg( + "skip", + index=0, + num_args=@argparse.ValueRange(lower=0, upper=0), + ), + @argparse.PositionalArg("name", index=1, required=true), + ]).parse(argv=["alice"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="empty value range (0..0) is unsupported") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "env parsing for settrue setfalse count and invalid values" { + let cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg( + "on", + long="on", + action=@argparse.FlagAction::SetTrue, + env="ON", + ), + @argparse.FlagArg( + "off", + long="off", + action=@argparse.FlagAction::SetFalse, + env="OFF", + ), + @argparse.FlagArg( + "v", + long="v", + action=@argparse.FlagAction::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": @argparse.ValueSource::Env, + "off": @argparse.ValueSource::Env, + "v": @argparse.ValueSource::Env, + .. + }, + ) + + try cmd.parse(argv=[], env={ "ON": "bad" }) catch { + @argparse.ArgParseError::InvalidValue(msg) => + inspect( + msg, + content=( + #|invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "OFF": "bad" }) catch { + @argparse.ArgParseError::InvalidValue(msg) => + inspect( + msg, + content=( + #|invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "V": "bad" }) catch { + @argparse.ArgParseError::InvalidValue(msg) => + inspect( + msg, + content=( + #|invalid value 'bad' for count; expected a non-negative integer + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "V": "-1" }) catch { + @argparse.ArgParseError::InvalidValue(msg) => + inspect( + msg, + content=( + #|invalid value '-1' for count; expected a non-negative integer + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "defaults and value range helpers through public API" { + let defaults = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "mode", + long="mode", + action=@argparse.OptionAction::Append, + default_values=["a", "b"], + ), + @argparse.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": @argparse.ValueSource::Default, + "one": @argparse.ValueSource::Default, + .. + }, + ) + + let upper_only = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::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", args=[ + @argparse.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 { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--tag") + _ => panic() + } 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", args=[ + @argparse.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": @argparse.ValueSource::Argv, .. }) + + try cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "set options reject duplicate occurrences" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("mode", long="mode"), + ]) + try cmd.parse(argv=["--mode", "a", "--mode", "b"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(msg) => + inspect(msg, content="argument '--mode' cannot be used multiple times") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "flag and option args require short or long names" { + try + @argparse.Command("demo", args=[@argparse.OptionArg("input")]).parse( + argv=[], + env=empty_env(), + ) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="flag/option args require short/long") + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[@argparse.FlagArg("verbose")]).parse( + argv=[], + env=empty_env(), + ) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="flag/option args require short/long") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "append options collect values across repeated occurrences" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "arg", + long="arg", + action=@argparse.OptionAction::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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "option parsing stops at the next option token" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("arg", short='a', long="arg"), + @argparse.FlagArg("verbose", long="verbose"), + ]) + + 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 { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "options always require a value" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("opt", long="opt"), + @argparse.FlagArg("verbose", long="verbose"), + ]) + try cmd.parse(argv=["--opt", "--verbose"], env=empty_env()) catch { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--opt") + _ => panic() + } noraise { + _ => panic() + } + + let zero_value_required = @argparse.Command("demo", args=[ + @argparse.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", args=[ + @argparse.OptionArg("pattern", long="pattern"), + ]) + let mut rejected = false + try strict.parse(argv=["--pattern", "-file"], env=empty_env()) catch { + @argparse.ArgParseError::MissingValue(name) => { + assert_true(name == "--pattern") + rejected = true + } + @argparse.ArgParseError::UnknownArgument(arg, _) => { + assert_true(arg == "-f") + rejected = true + } + _ => panic() + } noraise { + _ => () + } + assert_true(rejected) + + let permissive = @argparse.Command("demo", args=[ + @argparse.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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "from_matches uses public decoding hook" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("name", long="name"), + ]) + let matches = cmd.parse(argv=["--name", "alice"], env=empty_env()) catch { + _ => panic() + } + let decoded : DecodeName = @argparse.from_matches(matches) catch { + _ => panic() + } + assert_true(decoded.name == "alice") +} + +///| +test "default argv path is reachable" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "rest", + num_args=@argparse.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", args=[ + @argparse.OptionArg("x", long="x", last=true), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|positional-only settings require no short/long + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.FlagArg("f", action=@argparse.FlagAction::Help), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|flag/option args require short/long + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.FlagArg( + "f", + long="f", + action=@argparse.FlagAction::Help, + negatable=true, + ), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|help/version actions do not support negatable + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.FlagArg( + "f", + long="f", + action=@argparse.FlagAction::Help, + env="F", + ), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|help/version actions do not support env/defaults + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[@argparse.OptionArg("x", long="x")]).parse( + argv=["--x", "a", "b"], + env=empty_env(), + ) + catch { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg("x", long="x", default_values=["a", "b"]), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|default_values with multiple entries require action=Append + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "x", + num_args=@argparse.ValueRange(lower=3, upper=2), + ), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|max values must be >= min values + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "x", + num_args=@argparse.ValueRange(lower=-1, upper=2), + ), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|min values must be >= 0 + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "x", + num_args=@argparse.ValueRange(lower=0, upper=-1), + ), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|max values must be >= 0 + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "x", + index=0, + num_args=@argparse.ValueRange(lower=0, upper=2), + ), + @argparse.PositionalArg("y", index=1), + ]).parse(argv=["a"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|indexed positional 'x' cannot set num_args unless it is the last positional or exactly 1..1 + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.PositionalArg("x", index=0), + @argparse.PositionalArg("y", index=0), + ]).parse(argv=["a"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate positional index: 0 + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[ + @argparse.ArgGroup("g"), + @argparse.ArgGroup("g"), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate group: g + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[@argparse.ArgGroup("g", requires=["g"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|group cannot require itself: g + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[ + @argparse.ArgGroup("g", conflicts_with=["g"]), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|group cannot conflict with itself: g + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", groups=[@argparse.ArgGroup("g", args=["missing"])]).parse( + argv=[], + env=empty_env(), + ) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|unknown group arg: g -> missing + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg("x", long="x"), + @argparse.OptionArg("x", long="y"), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate arg name: x + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg("x", long="same"), + @argparse.OptionArg("y", long="same"), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate long option: --same + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.FlagArg("hello", long="hello", negatable=true), + @argparse.FlagArg("x", long="no-hello"), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate long option: --no-hello + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg("x", short='s'), + @argparse.OptionArg("y", short='s'), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate short option: -s + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.FlagArg("x", long="x", requires=["x"]), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|arg cannot require itself: x + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.FlagArg("x", long="x", conflicts_with=["x"]), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|arg cannot conflict with itself: x + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", subcommands=[ + @argparse.Command("x"), + @argparse.Command("x"), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|duplicate subcommand: x + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", subcommand_required=true).parse( + argv=[], + env=empty_env(), + ) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|subcommand_required requires at least one subcommand + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", subcommands=[@argparse.Command("help")]).parse( + argv=[], + env=empty_env(), + ) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|subcommand name reserved for built-in help: help (disable with disable_help_subcommand) + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + let custom_help = @argparse.Command("demo", args=[ + @argparse.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", args=[ + @argparse.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", args=[ + @argparse.FlagArg("v", long="v", action=@argparse.FlagAction::Version), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect( + msg, + content=( + #|version action requires command version text + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "builtin and custom help/version dispatch edge paths" { + let versioned = @argparse.Command("demo", version="1.2.3") + try versioned.parse(argv=["-V"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "1.2.3") + _ => panic() + } noraise { + _ => panic() + } + + try versioned.parse(argv=["--help"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + assert_true(text.has_prefix("Usage: demo")) + _ => panic() + } noraise { + _ => panic() + } + + try versioned.parse(argv=["-hV"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(_) => () + _ => panic() + } noraise { + _ => panic() + } + + try versioned.parse(argv=["-Vh"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "1.2.3") + _ => panic() + } noraise { + _ => panic() + } + + let long_help = @argparse.Command("demo", args=[ + @argparse.FlagArg( + "assist", + long="assist", + action=@argparse.FlagAction::Help, + ), + ]) + try long_help.parse(argv=["--assist"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + assert_true(text.has_prefix("Usage: demo")) + _ => panic() + } noraise { + _ => panic() + } + + let short_help = @argparse.Command("demo", args=[ + @argparse.FlagArg("assist", short='?', action=@argparse.FlagAction::Help), + ]) + try short_help.parse(argv=["-?"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + assert_true(text.has_prefix("Usage: demo")) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "subcommand lookup falls back to positional value" { + let cmd = @argparse.Command( + "demo", + args=[@argparse.PositionalArg("input", index=0)], + subcommands=[@argparse.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=[ + @argparse.ArgGroup("g", requires=["missing"]), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="unknown group requires target: g -> missing") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "group validation catches unknown conflicts_with target" { + try + @argparse.Command("demo", groups=[ + @argparse.ArgGroup("g", conflicts_with=["missing"]), + ]).parse(argv=[], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="unknown group conflicts_with target: g -> missing") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "group requires/conflicts can target argument names" { + let requires_cmd = @argparse.Command( + "demo", + groups=[@argparse.ArgGroup("mode", args=["fast"], requires=["config"])], + args=[ + @argparse.FlagArg("fast", long="fast"), + @argparse.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 { + @argparse.ArgParseError::MissingRequired(name) => + assert_true(name == "config") + @argparse.ArgParseError::MissingGroup(name) => assert_true(name == "config") + _ => panic() + } noraise { + _ => panic() + } + + let conflicts_cmd = @argparse.Command( + "demo", + groups=[ + @argparse.ArgGroup("mode", args=["fast"], conflicts_with=["config"]), + ], + args=[ + @argparse.FlagArg("fast", long="fast"), + @argparse.OptionArg("config", long="config"), + ], + ) + + try + conflicts_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) + catch { + @argparse.ArgParseError::GroupConflict(msg) => + inspect(msg, content="mode conflicts with config") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "group without members has no parse effect" { + let cmd = @argparse.Command("demo", groups=[@argparse.ArgGroup("known")], args=[ + @argparse.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", args=[ + @argparse.OptionArg("mode", long="mode", requires=["missing"]), + ]).parse(argv=["--mode", "fast"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="unknown requires target: mode -> missing") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "arg validation catches unknown conflicts_with target" { + try + @argparse.Command("demo", args=[ + @argparse.OptionArg("mode", long="mode", conflicts_with=["missing"]), + ]).parse(argv=["--mode", "fast"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="unknown conflicts_with target: mode -> missing") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "empty groups without presence do not fail" { + let grouped_ok = @argparse.Command( + "demo", + groups=[ + @argparse.ArgGroup("left", args=["l"]), + @argparse.ArgGroup("right", args=["r"]), + ], + args=[ + @argparse.FlagArg("l", long="left"), + @argparse.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", args=[ + @argparse.PositionalArg( + "files", + index=0, + required=true, + num_args=@argparse.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", args=[ + @argparse.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=["-h"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(_) => () + _ => panic() + } noraise { + _ => panic() + } + try short_only_builtin.parse(argv=["--help"], env=empty_env()) catch { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--help") + _ => panic() + } noraise { + _ => panic() + } + + let long_only_builtin = @argparse.Command("demo", args=[ + @argparse.FlagArg("custom_h", short='h'), + ]) + let long_only_text = long_only_builtin.render_help() + assert_true(long_only_text.has_prefix("Usage: demo")) + try long_only_builtin.parse(argv=["--help"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(_) => () + _ => panic() + } noraise { + _ => panic() + } + 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", args=[ + @argparse.PositionalArg("item", index=0), + ]) + 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=[ + @argparse.Command("run"), + ]) + let sub_help = sub_visible.render_help() + assert_true(sub_help.has_prefix("Usage: demo [command]")) +} + +///| +test "parse error formatting covers public variants" { + assert_true( + @argparse.ArgParseError::UnknownArgument("--oops", None).to_string() == + "error: unexpected argument '--oops' found", + ) + assert_true( + @argparse.ArgParseError::InvalidArgument("--bad").to_string() == + "error: unexpected argument '--bad' found", + ) + assert_true( + @argparse.ArgParseError::InvalidArgument("custom message").to_string() == + "error: custom message", + ) + assert_true( + @argparse.ArgParseError::MissingValue("--name").to_string() == + "error: a value is required for '--name' but none was supplied", + ) + assert_true( + @argparse.ArgParseError::MissingRequired("name").to_string() == + "error: the following required argument was not provided: 'name'", + ) + assert_true( + @argparse.ArgParseError::TooFewValues("tag", 1, 2).to_string() == + "error: 'tag' requires at least 2 values but only 1 were provided", + ) + assert_true( + @argparse.ArgParseError::TooManyValues("tag", 3, 2).to_string() == + "error: 'tag' allows at most 2 values but 3 were provided", + ) + assert_true( + @argparse.ArgParseError::InvalidValue("bad int").to_string() == + "error: bad int", + ) + assert_true( + @argparse.ArgParseError::MissingGroup("mode").to_string() == + "error: the following required argument group was not provided: 'mode'", + ) + assert_true( + @argparse.ArgParseError::GroupConflict("mode").to_string() == + "error: group conflict mode", + ) +} + +///| +test "options require one value per occurrence" { + let with_value = @argparse.Command("demo", args=[ + @argparse.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", args=[@argparse.OptionArg("tag", long="tag")]).parse( + argv=["--tag"], + env=empty_env(), + ) + catch { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--tag") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "short options require one value before next option token" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("x", short='x'), + @argparse.FlagArg("verbose", short='v'), + ]) + 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 { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "-x") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "version action dispatches on custom long and short flags" { + let cmd = @argparse.Command("demo", version="2.0.0", args=[ + @argparse.FlagArg( + "show_long", + long="show-version", + action=@argparse.FlagAction::Version, + ), + @argparse.FlagArg( + "show_short", + short='S', + action=@argparse.FlagAction::Version, + ), + ]) + + try cmd.parse(argv=["--show-version"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "2.0.0") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-S"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "2.0.0") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "global version action keeps parent version text in subcommand context" { + let cmd = @argparse.Command( + "demo", + version="1.0.0", + args=[ + @argparse.FlagArg( + "show_version", + short='S', + long="show-version", + action=@argparse.FlagAction::Version, + global=true, + ), + ], + subcommands=[@argparse.Command("run")], + ) + + try cmd.parse(argv=["--show-version"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "1.0.0") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["run", "--show-version"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "1.0.0") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["run", "-S"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => assert_true(text == "1.0.0") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "required and env-fed ranged values validate after parsing" { + let required_cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("input", long="input", required=true), + ]) + try required_cmd.parse(argv=[], env=empty_env()) catch { + @argparse.ArgParseError::MissingRequired(name) => + assert_true(name == "input") + _ => panic() + } noraise { + _ => panic() + } + + let env_min_cmd = @argparse.Command("demo", args=[ + @argparse.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": @argparse.ValueSource::Env, .. }) +} + +///| +test "positionals honor explicit index sorting with last ranged positional" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "late", + index=2, + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + @argparse.PositionalArg("first", index=0), + @argparse.PositionalArg("mid", index=1), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c", "d"], env=empty_env()) catch { + _ => panic() + } + assert_true( + parsed.values is { "first": ["a"], "mid": ["b"], "late": ["c", "d"], .. }, + ) +} + +///| +test "positional num_args lower bound rejects missing argv values" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "first", + index=0, + num_args=@argparse.ValueRange(lower=2, upper=3), + ), + ]) + + try cmd.parse(argv=[], env=empty_env()) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "first") + assert_true(got == 0) + assert_true(min == 2) + } + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "positional max clamp leaves trailing value for next positional" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "items", + num_args=@argparse.ValueRange(lower=0, upper=2), + ), + @argparse.PositionalArg("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", args=[ + @argparse.OptionArg("arg", long="arg", allow_hyphen_values=true), + @argparse.FlagArg("verbose", long="verbose"), + @argparse.FlagArg("cache", long="cache", negatable=true), + @argparse.FlagArg("quiet", short='q'), + ]) + + 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", args=[ + @argparse.OptionArg("arg", long="arg", allow_hyphen_values=true), + @argparse.PositionalArg( + "rest", + index=0, + num_args=@argparse.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", args=[ + @argparse.OptionArg("one", long="one"), + @argparse.FlagArg("verbose", long="verbose"), + ]) + + 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", args=[ + @argparse.OptionArg("arg", long="arg"), + @argparse.FlagArg("verbose", long="verbose"), + ]) + + 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 { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--arg") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "short-only set options use short label in duplicate errors" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("mode", short='m'), + ]) + try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(msg) => + inspect(msg, content="argument '-m' cannot be used multiple times") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "unknown short suggestion can be absent" { + let cmd = @argparse.Command("demo", disable_help_flag=true, args=[ + @argparse.OptionArg("name", long="name"), + ]) + + try cmd.parse(argv=["-x"], env=empty_env()) catch { + @argparse.ArgParseError::UnknownArgument(arg, hint) => { + assert_true(arg == "-x") + assert_true(hint is None) + } + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "setfalse flags apply false when present" { + let cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg( + "failfast", + long="failfast", + action=@argparse.FlagAction::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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "allow_hyphen positional treats unknown long token as value" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg("input", index=0, allow_hyphen_values=true), + @argparse.FlagArg("known", long="known"), + ]) + 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", + args=[ + @argparse.OptionArg( + "mode", + long="mode", + default_values=["safe"], + global=true, + ), + @argparse.OptionArg("unused", long="unused", global=true), + ], + subcommands=[@argparse.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": @argparse.ValueSource::Default, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["safe"], .. } && + sub.sources is { "mode": @argparse.ValueSource::Default, .. }, + ) +} + +///| +test "child global arg with inherited global name updates parent global" { + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.OptionArg( + "mode", + long="mode", + default_values=["safe"], + global=true, + ), + ], + subcommands=[ + @argparse.Command("run", args=[ + @argparse.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": @argparse.ValueSource::Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": @argparse.ValueSource::Argv, .. }, + ) +} + +///| +test "child local arg shadowing inherited global is rejected at build time" { + try + @argparse.Command( + "demo", + args=[ + @argparse.OptionArg( + "mode", + long="mode", + env="MODE", + default_values=["safe"], + global=true, + ), + ], + subcommands=[ + @argparse.Command("run", args=[@argparse.OptionArg("mode", long="mode")]), + ], + ).parse(argv=["run"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + assert_true(msg.contains("shadow")) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "global append env value from child is merged back to parent" { + let cmd = @argparse.Command( + "demo", + args=[ + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::Append, + env="TAG", + global=true, + ), + ], + subcommands=[@argparse.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": @argparse.ValueSource::Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "tag": ["env-tag"], .. } && + sub.sources is { "tag": @argparse.ValueSource::Env, .. }, + ) +} + +///| +test "global flag set in child argv is merged back to parent" { + let cmd = @argparse.Command( + "demo", + args=[@argparse.FlagArg("verbose", long="verbose", global=true)], + subcommands=[@argparse.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": @argparse.ValueSource::Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": @argparse.ValueSource::Argv, .. }, + ) +} diff --git a/argparse/argparse_test.mbt b/argparse/argparse_test.mbt new file mode 100644 index 000000000..63e82a27b --- /dev/null +++ b/argparse/argparse_test.mbt @@ -0,0 +1,425 @@ +// 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", args=[ + @argparse.FlagArg("verbose", short='v', long="verbose"), + @argparse.OptionArg("count", long="count", env="COUNT"), + @argparse.PositionalArg("name", index=0), + ]) + 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"], .. }) +} + +///| +test "display help and version" { + let cmd = @argparse.Command("demo", about="demo app", version="1.2.3") + + let mut help = "" + try cmd.parse(argv=["-h"], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => help = text + _ => panic() + } noraise { + _ => panic() + } + inspect( + help, + content=( + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + let mut version = "" + try cmd.parse(argv=["--version"], env=empty_env()) catch { + @argparse.DisplayVersion::Message(text) => version = text + _ => panic() + } noraise { + _ => panic() + } + inspect(version, content="1.2.3") +} + +///| +test "parse error show is readable" { + inspect( + @argparse.ArgParseError::UnknownArgument("--verbse", Some("--verbose")).to_string(), + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + ), + ) + inspect( + @argparse.ArgParseError::TooManyPositionals.to_string(), + content=( + #|error: too many positional arguments were provided + ), + ) +} + +///| +test "relationships and num args" { + let requires_cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg("mode", long="mode", requires=["config"]), + @argparse.OptionArg("config", long="config"), + ]) + + try requires_cmd.parse(argv=["--mode", "fast"], env=empty_env()) catch { + @argparse.ArgParseError::MissingRequired(name) => + inspect(name, content="config") + _ => panic() + } noraise { + _ => panic() + } + + let appended = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::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=[ + @argparse.ArgGroup("mode", required=true, multiple=false, args=[ + "fast", "slow", + ]), + ], + args=[ + @argparse.FlagArg("fast", long="fast"), + @argparse.FlagArg("slow", long="slow"), + ], + ) + + try cmd.parse(argv=[], env=empty_env()) catch { + @argparse.ArgParseError::MissingGroup(name) => inspect(name, content="mode") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--fast", "--slow"], env=empty_env()) catch { + @argparse.ArgParseError::GroupConflict(name) => + inspect(name, content="mode") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "arg groups requires and conflicts" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ + @argparse.ArgGroup("mode", args=["fast"], requires=["output"]), + @argparse.ArgGroup("output", args=["json"]), + ], + args=[ + @argparse.FlagArg("fast", long="fast"), + @argparse.FlagArg("json", long="json"), + ], + ) + + try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { + @argparse.ArgParseError::MissingGroup(name) => + inspect(name, content="output") + _ => panic() + } noraise { + _ => panic() + } + + let conflict_cmd = @argparse.Command( + "demo", + groups=[ + @argparse.ArgGroup("mode", args=["fast"], conflicts_with=["output"]), + @argparse.ArgGroup("output", args=["json"]), + ], + args=[ + @argparse.FlagArg("fast", long="fast"), + @argparse.FlagArg("json", long="json"), + ], + ) + + try conflict_cmd.parse(argv=["--fast", "--json"], env=empty_env()) catch { + @argparse.ArgParseError::GroupConflict(msg) => + inspect( + msg, + content=( + #|mode conflicts with output + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "subcommand parsing" { + let echo = @argparse.Command("echo", args=[ + @argparse.PositionalArg("msg", index=0), + ]) + 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", + args=[ + @argparse.FlagArg( + "verbose", + short='v', + long="verbose", + about="Enable verbose mode", + ), + @argparse.OptionArg("count", long="count", about="Repeat count", default_values=[ + "1", + ]), + @argparse.PositionalArg("name", index=0, about="Target name"), + ], + subcommands=[@argparse.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", args=[ + @argparse.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": @argparse.ValueSource::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": @argparse.ValueSource::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": @argparse.ValueSource::Argv, .. }) +} + +///| +test "omitted env does not read process environment by default" { + let cmd = @argparse.Command("demo", args=[ + @argparse.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", + args=[ + @argparse.OptionArg("count", short='c', long="count"), + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::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", args=[ + @argparse.FlagArg("cache", long="cache", negatable=true), + @argparse.FlagArg( + "failfast", + long="failfast", + action=@argparse.FlagAction::SetFalse, + negatable=true, + ), + @argparse.FlagArg("verbose", long="verbose", conflicts_with=["quiet"]), + @argparse.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": @argparse.ValueSource::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 { + @argparse.ArgParseError::InvalidArgument(msg) => + inspect( + msg, + content=( + #|conflicting arguments: verbose and quiet + ), + ) + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "flag does not accept inline value" { + let cmd = @argparse.Command("demo", args=[ + @argparse.FlagArg("verbose", long="verbose"), + ]) + try cmd.parse(argv=["--verbose=true"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(arg) => + inspect(arg, content="--verbose=true") + _ => panic() + } 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 { + @argparse.ArgParseError::InvalidArgument(arg) => + inspect(arg, content="--help=1") + _ => panic() + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--version=1"], env=empty_env()) catch { + @argparse.ArgParseError::InvalidArgument(arg) => + inspect(arg, content="--version=1") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "command policies" { + let help_cmd = @argparse.Command("demo", arg_required_else_help=true) + try help_cmd.parse(argv=[], env=empty_env()) catch { + @argparse.DisplayHelp::Message(text) => + inspect( + text, + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + let sub_cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ + @argparse.Command("echo"), + ]) + assert_true(sub_cmd.render_help().has_prefix("Usage: demo ")) + try sub_cmd.parse(argv=[], env=empty_env()) catch { + @argparse.ArgParseError::MissingRequired(name) => + inspect(name, content="subcommand") + _ => panic() + } noraise { + _ => panic() + } +} diff --git a/argparse/command.mbt b/argparse/command.mbt new file mode 100644 index 000000000..37141072c --- /dev/null +++ b/argparse/command.mbt @@ -0,0 +1,233 @@ +// 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 : String, + args? : Array[&ArgLike], + subcommands? : Array[Command], + about? : String, + version? : String, + disable_help_flag? : Bool, + disable_version_flag? : Bool, + disable_help_subcommand? : Bool, + arg_required_else_help? : Bool, + subcommand_required? : Bool, + hidden? : Bool, + groups? : Array[ArgGroup], + ) -> Command +} + +///| +/// Create a declarative command specification. +/// +/// Notes: +/// - `args` accepts `FlagArg` / `OptionArg` / `PositionalArg` via `ArgLike`. +/// - `groups` explicitly declares all group memberships and policies. +/// - Built-in `--help`/`--version` behavior can be disabled with the flags below. +pub fn Command::new( + name : String, + args? : Array[&ArgLike] = [], + subcommands? : Array[Command] = [], + about? : String, + version? : String, + 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? : Array[ArgGroup] = [], +) -> Command { + let (parsed_args, arg_error) = collect_args(args) + let groups = groups.copy() + let cmd = Command::{ + name, + args: parsed_args, + groups, + subcommands: subcommands.copy(), + about, + version, + 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, @set.new()) 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. +/// +/// Error and event model: +/// - Raises `DisplayHelp::Message` / `DisplayVersion::Message` for display +/// actions instead of exiting the process. +/// - Raises `ArgBuildError` when the command definition is invalid. +/// - Raises `ArgParseError` when user input does not satisfy the definition. +/// +/// Value precedence is `argv > env > default_values`. +pub fn Command::parse( + self : Command, + argv? : Array[String] = default_argv(), + env? : Map[String, String] = {}, +) -> Matches raise { + let raw = parse_command(self, argv, env, [], {}, {}) + build_matches(self, raw, []) +} + +///| +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) + match raw.values.get(name) { + Some(vs) => values[name] = vs.copy() + None => () + } + let count = raw.counts.get(name).unwrap_or(0) + if count > 0 { + flag_counts[name] = count + } + let source = match raw.flag_sources.get(name) { + Some(v) => Some(v) + None => + match raw.value_sources.get(name) { + Some(v) => Some(v) + None => None + } + } + match source { + Some(source) => { + sources[name] = source + if is_flag_spec(spec) { + if is_count_flag_spec(spec) { + flags[name] = count > 0 + } else { + flags[name] = raw.flags.get(name).unwrap_or(false) + } + } + } + None => () + } + } + let child_globals = inherited_globals + + cmd.args.filter(arg => { + arg.global && (arg.long is Some(_) || arg.short is Some(_)) + }) + + 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, + Matches::{ + flags: {}, + values: {}, + flag_counts: {}, + sources: {}, + subcommand: None, + counts: {}, + flag_sources: {}, + value_sources: {}, + parsed_subcommand: None, + }, + ), + ) + } + None => None + } + + Matches::{ + 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(specs : Array[&ArgLike]) -> (Array[Arg], ArgBuildError?) { + let args = specs.map(spec => spec.to_arg()) + let ctx = ValidationCtx::new() + let mut first_error : ArgBuildError? = None + for spec in specs { + spec.validate(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..fcdf17ba4 --- /dev/null +++ b/argparse/error.mbt @@ -0,0 +1,85 @@ +// 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. + +///| +/// Errors raised during parsing or decoding of arguments. +pub(all) suberror ArgParseError { + UnknownArgument(String, String?) + InvalidArgument(String) + MissingValue(String) + MissingRequired(String) + TooFewValues(String, Int, Int) + TooManyValues(String, Int, Int) + TooManyPositionals + InvalidValue(String) + MissingGroup(String) + GroupConflict(String) +} + +///| +pub impl Show for ArgParseError with output(self : ArgParseError, logger) { + logger.write_string(self.arg_parse_error_message()) +} + +///| +fn ArgParseError::arg_parse_error_message(self : ArgParseError) -> String { + match self { + ArgParseError::UnknownArgument(arg, Some(hint)) => + ( + $|error: unexpected argument '\{arg}' found + $| + $| tip: a similar argument exists: '\{hint}' + ) + ArgParseError::UnknownArgument(arg, None) => + "error: unexpected argument '\{arg}' found" + ArgParseError::InvalidArgument(arg) => + if arg.has_prefix("-") { + "error: unexpected argument '\{arg}' found" + } else { + "error: \{arg}" + } + ArgParseError::MissingValue(arg) => + "error: a value is required for '\{arg}' but none was supplied" + ArgParseError::MissingRequired(name) => + "error: the following required argument was not provided: '\{name}'" + ArgParseError::TooFewValues(name, got, min) => + "error: '\{name}' requires at least \{min} values but only \{got} were provided" + ArgParseError::TooManyValues(name, got, max) => + "error: '\{name}' allows at most \{max} values but \{got} were provided" + ArgParseError::TooManyPositionals => + "error: too many positional arguments were provided" + ArgParseError::InvalidValue(msg) => "error: \{msg}" + ArgParseError::MissingGroup(name) => + "error: the following required argument group was not provided: '\{name}'" + ArgParseError::GroupConflict(name) => "error: group conflict \{name}" + } +} + +///| +/// Errors raised when building argument specifications. +pub suberror ArgBuildError { + Unsupported(String) +} derive(Show) + +///| +/// Errors raised when help information is requested. +pub suberror DisplayHelp { + Message(String) +} derive(Show) + +///| +/// Errors raised when version information is requested. +pub suberror DisplayVersion { + Message(String) +} derive(Show) diff --git a/argparse/help_render.mbt b/argparse/help_render.mbt new file mode 100644 index 000000000..592b8e3b2 --- /dev/null +++ b/argparse/help_render.mbt @@ -0,0 +1,394 @@ +// 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 { + let usage_line = "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 = "" + 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.long is Some(_) || arg.short is Some(_) { + return true + } + } + false +} + +///| +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 { + if arg.long is None && arg.short is None { + continue + } + if arg.hidden { + continue + } + let name = if arg_takes_value(arg) { + "\{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.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.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) + if arg.short is Some(short) { + parts.push("-\{short}") + } + if arg.long is Some(long) { + if arg.negatable && !arg_takes_value(arg) { + 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 => () + } + match arg.default_values { + Some(values) if values.length() > 1 => { + let defaults = values.join(", ") + notes.push("defaults: \{defaults}") + } + Some(values) if values.length() == 1 => notes.push("default: \{values[0]}") + _ => () + } + if is_required_arg(arg) { + notes.push("required") + } + 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 flags = [] + if group.required { + flags.push("required") + } + if !group.multiple { + flags.push("exclusive") + } + if flags.length() == 0 { + group.name + } else { + let flags_text = flags.join(", ") + "\{group.name} (\{flags_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) + if is_positional_arg(arg) { + base + } else if arg_takes_value(arg) { + "\{base} <\{arg.name}>" + } else { + base + } +} diff --git a/argparse/matches.mbt b/argparse/matches.mbt new file mode 100644 index 000000000..886e87c6b --- /dev/null +++ b/argparse/matches.mbt @@ -0,0 +1,64 @@ +// 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. + +///| +/// Where a value/flag came from. +pub enum ValueSource { + Argv + Env + Default +} derive(Eq, Show) + +///| +/// Parse results for declarative commands. +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)? +} + +///| +fn new_matches_parse_state() -> Matches { + Matches::{ + flags: {}, + values: {}, + flag_counts: {}, + sources: {}, + subcommand: None, + counts: {}, + flag_sources: {}, + value_sources: {}, + parsed_subcommand: None, + } +} + +///| +/// Decode a full argument struct/enum from `Matches`. +pub(open) trait FromMatches { + from_matches(matches : Matches) -> Self raise ArgParseError +} + +///| +/// Decode a full argument struct/enum from `Matches`. +pub fn[T : FromMatches] from_matches( + matches : Matches, +) -> T raise ArgParseError { + T::from_matches(matches) +} diff --git a/argparse/moon.pkg b/argparse/moon.pkg new file mode 100644 index 000000000..101bb7edf --- /dev/null +++ b/argparse/moon.pkg @@ -0,0 +1,6 @@ +import { + "moonbitlang/core/builtin", + "moonbitlang/core/env", + "moonbitlang/core/strconv", + "moonbitlang/core/set", +} diff --git a/argparse/parser.mbt b/argparse/parser.mbt new file mode 100644 index 000000000..557149122 --- /dev/null +++ b/argparse/parser.mbt @@ -0,0 +1,438 @@ +// 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 DisplayHelp::Message(text) +} + +///| +fn raise_version(text : String) -> Unit raise DisplayVersion { + raise DisplayVersion::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 ArgParseError::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 ArgParseError::UnknownArgument("-\{short}", hint) +} + +///| +fn[T] raise_subcommand_conflict(name : String) -> T raise ArgParseError { + raise ArgParseError::InvalidArgument( + "subcommand '\{name}' cannot be used with positional arguments", + ) +} + +///| +fn render_help_for_context( + cmd : Command, + inherited_globals : Array[Arg], +) -> String { + let help_cmd = if inherited_globals.length() == 0 { + cmd + } else { + Command::{ ..cmd, args: inherited_globals + cmd.args } + } + render_help(help_cmd) +} + +///| +fn raise_context_help( + cmd : Command, + inherited_globals : Array[Arg], +) -> Unit raise DisplayHelp { + raise_help(render_help_for_context(cmd, inherited_globals)) +} + +///| +fn default_argv() -> Array[String] { + let args = @env.args() + if args.length() > 1 { + args[1:].to_array() + } else { + [] + } +} + +///| +fn parse_command( + cmd : Command, + argv : Array[String], + env : Map[String, String], + inherited_globals : Array[Arg], + inherited_version_long : Map[String, String], + inherited_version_short : Map[Char, String], +) -> 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) + } + let matches = new_matches_parse_state() + let globals_here = collect_globals(args) + let child_globals = 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 arg_action(global) == ArgAction::Version { + if global.long is Some(name) { + child_version_long[name] = command_version(cmd) + } + if global.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 last_pos_idx = positionals.search_by(arg => arg.last) + 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 + } + let force_positional = match last_pos_idx { + Some(idx) => positional_values.length() >= idx + None => false + } + if force_positional { + positional_values.push(arg) + positional_arg_found = true + i = i + 1 + continue + } + if builtin_help_short && arg == "-h" { + raise_context_help(cmd, inherited_globals) + } + if builtin_help_long && arg == "--help" { + raise_context_help(cmd, inherited_globals) + } + 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) + } + 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.has_prefix("no-") { + let target = match name.strip_prefix("no-") { + Some(view) => view.to_string() + None => "" + } + match long_index.get(target) { + None => raise_unknown_long(name, long_index) + Some(spec) => { + if !spec.negatable || arg_takes_value(spec) { + raise_unknown_long(name, long_index) + } + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + let value = match arg_action(spec) { + ArgAction::SetFalse => true + _ => false + } + if arg_action(spec) == ArgAction::Count { + matches.counts[spec.name] = 0 + } + matches.flags[spec.name] = value + matches.flag_sources[spec.name] = ValueSource::Argv + } + } + } else { + raise_unknown_long(name, long_index) + } + Some(spec) => + if arg_takes_value(spec) { + check_duplicate_set_occurrence(matches, spec) + if inline is Some(v) { + assign_value(matches, spec, v, ValueSource::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], ValueSource::Argv) + } else { + raise ArgParseError::MissingValue("--\{name}") + } + } + } else { + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + match arg_action(spec) { + ArgAction::Help => raise_context_help(cmd, inherited_globals) + ArgAction::Version => + raise_version( + version_text_for_long_action( + cmd, name, inherited_version_long, + ), + ) + _ => apply_flag(matches, spec, ValueSource::Argv) + } + } + } + i = i + 1 + continue + } + if arg.has_prefix("-") && arg != "-" { + // Parse short groups like `-abc` and short values like `-c3`. + let mut pos = 1 + while pos < arg.length() { + let short = arg.get_char(pos).unwrap() + if short == 'h' && builtin_help_short { + raise_context_help(cmd, inherited_globals) + } + 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 arg_takes_value(spec) { + check_duplicate_set_occurrence(matches, spec) + if pos + 1 < arg.length() { + let rest = arg.unsafe_substring(start=pos + 1, end=arg.length()) + let inline = match rest.strip_prefix("=") { + Some(view) => view.to_string() + None => rest + } + assign_value(matches, spec, inline, ValueSource::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], ValueSource::Argv) + } else { + raise ArgParseError::MissingValue("-\{short}") + } + } + break + } else { + match arg_action(spec) { + ArgAction::Help => raise_context_help(cmd, inherited_globals) + ArgAction::Version => + raise_version( + version_text_for_short_action( + cmd, short, inherited_version_short, + ), + ) + _ => apply_flag(matches, spec, ValueSource::Argv) + } + } + pos = pos + 1 + } + i = i + 1 + 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) = resolve_help_target( + cmd, rest, builtin_help_short, builtin_help_long, inherited_globals, + ) + let text = render_help_for_context(target, target_globals) + 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_matches = parse_command( + sub, rest, env, child_globals, child_version_long, child_version_short, + ) + let child_local_non_globals = collect_non_global_names(sub.args) + 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 = 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 = 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.long is Some(name) && + name == long && + arg_action(arg) == ArgAction::Version { + 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.short is Some(value) && + value == short && + arg_action(arg) == ArgAction::Version { + 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..0e7716231 --- /dev/null +++ b/argparse/parser_globals_merge.mbt @@ -0,0 +1,249 @@ +// 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(ValueSource::Argv) => 3 + Some(ValueSource::Env) => 2 + Some(ValueSource::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(ValueSource::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 { + 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_action(arg) == ArgAction::Append { + let both_argv = parent_source is Some(ValueSource::Argv) && + child_source is Some(ValueSource::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] = ValueSource::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 { + 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_action(arg) == ArgAction::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(ValueSource::Argv) && + child_source is Some(ValueSource::Argv) + if both_argv { + let parent_count = parent.counts.get(name).unwrap_or(0) + let child_count = child.counts.get(name).unwrap_or(0) + 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 merge_globals_from_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_takes_value(arg) { + merge_global_value_from_child(parent, child, arg, name) + } else { + merge_global_flag_from_child(parent, child, 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_takes_value(arg) { + 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_action(arg) == ArgAction::Count { + match parent.counts.get(name) { + Some(c) => child.counts[name] = c + None => () + } + } + } + None => () + } + } + } +} diff --git a/argparse/parser_lookup.mbt b/argparse/parser_lookup.mbt new file mode 100644 index 000000000..256998815 --- /dev/null +++ b/argparse/parser_lookup.mbt @@ -0,0 +1,116 @@ +// 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 { + if arg.long is Some(name) { + index[name] = arg + } + } + for arg in args { + if arg.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.short is Some(value) { + index[value] = arg + } + } + for arg in args { + if arg.short is Some(value) { + index[value] = arg + } + } + index +} + +///| +fn collect_globals(args : Array[Arg]) -> Array[Arg] { + args.filter(arg => arg.global && (arg.long is Some(_) || arg.short is Some(_))) +} + +///| +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, Array[Arg]) raise ArgParseError { + let targets = if argv.length() == 0 { + argv + } else { + let last = argv[argv.length() - 1] + if (last == "-h" && builtin_help_short) || + (last == "--help" && builtin_help_long) { + argv[:argv.length() - 1].to_array() + } else { + argv + } + } + let mut current = cmd + let mut current_globals = inherited_globals + let mut subs = cmd.subcommands + for name in targets { + if name.has_prefix("-") { + raise ArgParseError::InvalidArgument("unexpected help argument: \{name}") + } + guard subs.iter().find_first(sub => sub.name == name) is Some(sub) else { + raise ArgParseError::InvalidArgument("unknown subcommand: \{name}") + } + current_globals = current_globals + collect_globals(current.args) + current = sub + subs = sub.subcommands + } + (current, current_globals) +} + +///| +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_positionals.mbt b/argparse/parser_positionals.mbt new file mode 100644 index 000000000..3dc5aee5e --- /dev/null +++ b/argparse/parser_positionals.mbt @@ -0,0 +1,127 @@ +// 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 with_index = [] + let without_index = [] + for arg in args { + if is_positional_arg(arg) { + if arg.index is Some(idx) { + with_index.push((idx, arg)) + } else { + without_index.push(arg) + } + } + } + with_index.sort_by_key(pair => pair.0) + let ordered = [] + for item in with_index { + let (_, arg) = item + ordered.push(arg) + } + for arg in without_index { + 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 allow = next.allow_hyphen_values || 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 + 1 + } + 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..be8344e3f --- /dev/null +++ b/argparse/parser_validate.mbt @@ -0,0 +1,548 @@ +// 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] + seen_positional_indices : @set.Set[Int] + args : Array[Arg] +} + +///| +fn ValidationCtx::new( + inherited_global_names? : @set.Set[String] = @set.new(), +) -> ValidationCtx { + ValidationCtx::{ + inherited_global_names: inherited_global_names.copy(), + seen_names: @set.new(), + seen_long: @set.new(), + seen_short: @set.new(), + seen_positional_indices: @set.new(), + args: [], + } +} + +///| +fn ValidationCtx::record_arg( + self : ValidationCtx, + arg : Arg, +) -> Unit raise ArgBuildError { + if !self.seen_names.add_and_check(arg.name) { + raise ArgBuildError::Unsupported("duplicate arg name: \{arg.name}") + } + if !arg.global && self.inherited_global_names.contains(arg.name) { + raise ArgBuildError::Unsupported( + "arg '\{arg.name}' shadows an inherited global; rename the arg or mark it global", + ) + } + if arg.long is Some(name) { + if !self.seen_long.add_and_check(name) { + raise ArgBuildError::Unsupported("duplicate long option: --\{name}") + } + if arg.negatable && !self.seen_long.add_and_check("no-\{name}") { + raise ArgBuildError::Unsupported("duplicate long option: --no-\{name}") + } + } + if arg.short is Some(short) && !self.seen_short.add_and_check(short) { + raise ArgBuildError::Unsupported("duplicate short option: -\{short}") + } + if arg.is_positional && + arg.index is Some(index) && + !self.seen_positional_indices.add_and_check(index) { + raise ArgBuildError::Unsupported("duplicate positional index: \{index}") + } + self.args.push(arg) +} + +///| +fn ValidationCtx::finalize(self : ValidationCtx) -> Unit raise ArgBuildError { + validate_requires_conflicts_targets(self.args, self.seen_names) + validate_indexed_positional_num_args(self.args) +} + +///| +fn validate_command( + cmd : Command, + args : Array[Arg], + groups : Array[ArgGroup], + inherited_global_names : @set.Set[String], +) -> Unit raise ArgBuildError { + match cmd.build_error { + Some(err) => raise err + None => () + } + validate_inherited_global_shadowing(args, inherited_global_names) + 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_global_names = inherited_global_names.copy() + for global in collect_globals(args) { + child_inherited_global_names.add(global.name) + } + for sub in cmd.subcommands { + validate_command(sub, sub.args, sub.groups, child_inherited_global_names) + } +} + +///| +fn validate_inherited_global_shadowing( + args : Array[Arg], + inherited_global_names : @set.Set[String], +) -> Unit raise ArgBuildError { + for arg in args { + if arg.global { + continue + } + if inherited_global_names.contains(arg.name) { + raise ArgBuildError::Unsupported( + "arg '\{arg.name}' shadows an inherited global; rename the arg or mark it global", + ) + } + } +} + +///| +fn validate_indexed_positional_num_args( + args : Array[Arg], +) -> Unit raise ArgBuildError { + let positionals = positional_args(args) + if positionals.length() <= 1 { + return + } + let mut idx = 0 + while idx + 1 < positionals.length() { + let arg = positionals[idx] + if arg.index is Some(_) { + match arg.num_args { + Some(range) => + if !(range.lower == 1 && range.upper is Some(1)) { + raise ArgBuildError::Unsupported( + "indexed positional '\{arg.name}' cannot set num_args unless it is the last positional or exactly 1..1", + ) + } + None => () + } + } + idx = idx + 1 + } +} + +///| +fn validate_flag_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + validate_named_option_arg(arg) + if arg.index is Some(_) || arg.last { + raise ArgBuildError::Unsupported( + "positional-only settings require no short/long", + ) + } + if arg.num_args is Some(_) { + raise ArgBuildError::Unsupported( + "min/max values require value-taking arguments", + ) + } + if arg.flag_action is (Help | Version) { + guard !arg.negatable else { + raise ArgBuildError::Unsupported( + "help/version actions do not support negatable", + ) + } + guard arg.env is None else { + raise ArgBuildError::Unsupported( + "help/version actions do not support env/defaults", + ) + } + guard !arg.multiple else { + raise ArgBuildError::Unsupported( + "help/version actions do not support multiple values", + ) + } + } + if arg.default_values is Some(_) { + raise ArgBuildError::Unsupported( + "default values require value-taking arguments", + ) + } + ctx.record_arg(arg) +} + +///| +fn validate_option_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + validate_named_option_arg(arg) + if arg.index is Some(_) || arg.last { + raise ArgBuildError::Unsupported( + "positional-only settings require no short/long", + ) + } + guard !arg.negatable else { + raise ArgBuildError::Unsupported("negatable is only supported for flags") + } + guard arg.num_args is None else { + raise ArgBuildError::Unsupported( + "min/max values require value-taking arguments", + ) + } + validate_default_values(arg) + ctx.record_arg(arg) +} + +///| +fn validate_positional_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + if arg.long is Some(_) || arg.short is Some(_) { + raise ArgBuildError::Unsupported( + "positional args do not support short/long", + ) + } + guard !arg.negatable else { + raise ArgBuildError::Unsupported("negatable is only supported for flags") + } + let (min, max) = arg_min_max_for_validate(arg) + if (min > 1 || (max is Some(m) && m > 1)) && !arg.multiple { + raise ArgBuildError::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.long is Some(_) || arg.short is Some(_) else { + raise ArgBuildError::Unsupported("flag/option args require short/long") + } +} + +///| +fn validate_default_values(arg : Arg) -> Unit raise ArgBuildError { + if arg.default_values is Some(values) && + values.length() > 1 && + !arg.multiple && + arg_action(arg) != ArgAction::Append { + raise ArgBuildError::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 ArgBuildError::Unsupported("duplicate group: \{group.name}") + } + } + for group in groups { + for required in group.requires { + if required == group.name { + raise ArgBuildError::Unsupported( + "group cannot require itself: \{group.name}", + ) + } + if !seen.contains(required) && !arg_seen.contains(required) { + raise ArgBuildError::Unsupported( + "unknown group requires target: \{group.name} -> \{required}", + ) + } + } + for conflict in group.conflicts_with { + if conflict == group.name { + raise ArgBuildError::Unsupported( + "group cannot conflict with itself: \{group.name}", + ) + } + if !seen.contains(conflict) && !arg_seen.contains(conflict) { + raise ArgBuildError::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 ArgBuildError::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 ArgBuildError::Unsupported( + "arg cannot require itself: \{arg.name}", + ) + } + if !seen_names.contains(required) { + raise ArgBuildError::Unsupported( + "unknown requires target: \{arg.name} -> \{required}", + ) + } + } + for conflict in arg.conflicts_with { + if conflict == arg.name { + raise ArgBuildError::Unsupported( + "arg cannot conflict with itself: \{arg.name}", + ) + } + if !seen_names.contains(conflict) { + raise ArgBuildError::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 ArgBuildError::Unsupported("duplicate subcommand: \{sub.name}") + } + } +} + +///| +fn validate_subcommand_required_policy( + cmd : Command, +) -> Unit raise ArgBuildError { + if cmd.subcommand_required && cmd.subcommands.length() == 0 { + raise ArgBuildError::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 ArgBuildError::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_action(arg) is ArgAction::Version) { + raise ArgBuildError::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 ArgParseError::MissingRequired("subcommand") + } +} + +///| +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 ArgParseError::MissingGroup(group.name) + } + if !group.multiple && count > 1 { + raise ArgParseError::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 ArgParseError::MissingGroup(required) + } + } else if arg_seen.contains(required) { + if !matches_has_value_or_flag(matches, required) { + raise ArgParseError::MissingRequired(required) + } + } + } + for conflict in group.conflicts_with { + if group_seen.contains(conflict) { + if group_presence.get(conflict).unwrap_or(0) > 0 { + raise ArgParseError::GroupConflict( + "\{group.name} conflicts with \{conflict}", + ) + } + } else if arg_seen.contains(conflict) { + if matches_has_value_or_flag(matches, conflict) { + raise ArgParseError::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 ArgParseError::MissingRequired(arg.name) + } + if !arg_takes_value(arg) { + continue + } + if !present { + if is_positional_arg(arg) { + let (min, _) = arg_min_max(arg) + if min > 0 { + raise ArgParseError::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 ArgParseError::TooFewValues(arg.name, count, min) + } + if arg_action(arg) != ArgAction::Append { + match max { + Some(max) if count > max => + raise ArgParseError::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 ArgParseError::MissingRequired(required) + } + } + for conflict in arg.conflicts_with { + if matches_has_value_or_flag(matches, conflict) { + raise ArgParseError::InvalidArgument( + "conflicting arguments: \{arg.name} and \{conflict}", + ) + } + } + } +} + +///| +fn is_positional_arg(arg : Arg) -> Bool { + arg.is_positional +} diff --git a/argparse/parser_values.mbt b/argparse/parser_values.mbt new file mode 100644 index 000000000..1e6964bfd --- /dev/null +++ b/argparse/parser_values.mbt @@ -0,0 +1,315 @@ +// 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, + ValueSource::Argv, + ) + taken = taken + 1 + } + cursor = cursor + taken + continue + } + if remaining > 0 { + add_value(matches, arg.name, values[cursor], arg, ValueSource::Argv) + cursor = cursor + 1 + } + } + if cursor < values.length() { + raise ArgParseError::TooManyPositionals + } +} + +///| +fn positional_min_required(arg : Arg) -> Int { + let (min, _) = arg_min_max(arg) + if min > 0 { + min + } else if arg.required { + 1 + } 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_action(arg) == ArgAction::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_action(arg) { + ArgAction::Append => add_value(matches, arg.name, value, arg, source) + ArgAction::Set => add_value(matches, arg.name, value, arg, source) + ArgAction::SetTrue => { + let flag = parse_bool(value) + matches.flags[arg.name] = flag + matches.flag_sources[arg.name] = source + } + ArgAction::SetFalse => { + let flag = parse_bool(value) + matches.flags[arg.name] = !flag + matches.flag_sources[arg.name] = source + } + ArgAction::Count => { + let count = parse_count(value) + matches.counts[arg.name] = count + matches.flags[arg.name] = count > 0 + matches.flag_sources[arg.name] = source + } + ArgAction::Help => + raise ArgParseError::InvalidArgument("help action does not take values") + ArgAction::Version => + raise ArgParseError::InvalidArgument( + "version action does not take values", + ) + } +} + +///| +fn option_conflict_label(arg : Arg) -> String { + match arg.long { + Some(name) => "--\{name}" + None => + match arg.short { + Some(short) => "-\{short}" + None => arg.name + } + } +} + +///| +fn check_duplicate_set_occurrence( + matches : Matches, + arg : Arg, +) -> Unit raise ArgParseError { + if arg_action(arg) != ArgAction::Set { + return + } + if matches.values.get(arg.name) is Some(_) { + raise ArgParseError::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.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_takes_value(arg) { + assign_value(matches, arg, value, ValueSource::Env) + continue + } + match arg_action(arg) { + ArgAction::Count => { + let count = parse_count(value) + matches.counts[name] = count + matches.flags[name] = count > 0 + matches.flag_sources[name] = ValueSource::Env + } + ArgAction::SetFalse => { + let flag = parse_bool(value) + matches.flags[name] = !flag + matches.flag_sources[name] = ValueSource::Env + } + ArgAction::SetTrue => { + let flag = parse_bool(value) + matches.flags[name] = flag + matches.flag_sources[name] = ValueSource::Env + } + ArgAction::Set => { + let flag = parse_bool(value) + matches.flags[name] = flag + matches.flag_sources[name] = ValueSource::Env + } + ArgAction::Append => () + ArgAction::Help => () + ArgAction::Version => () + } + } +} + +///| +fn apply_defaults(matches : Matches, args : Array[Arg]) -> Unit { + for arg in args { + if !arg_takes_value(arg) { + continue + } + if matches_has_value_or_flag(matches, arg.name) { + continue + } + match arg.default_values { + Some(values) if values.length() > 0 => + for value in values { + let _ = add_value(matches, arg.name, value, arg, ValueSource::Default) + } + _ => () + } + } +} + +///| +fn matches_has_value_or_flag(matches : Matches, name : String) -> Bool { + matches.flags.get(name) is Some(_) || matches.values.get(name) is Some(_) +} + +///| +fn apply_flag(matches : Matches, arg : Arg, source : ValueSource) -> Unit { + match arg_action(arg) { + ArgAction::SetTrue => matches.flags[arg.name] = true + ArgAction::SetFalse => matches.flags[arg.name] = false + ArgAction::Count => { + let current = matches.counts.get(arg.name).unwrap_or(0) + matches.counts[arg.name] = current + 1 + matches.flags[arg.name] = true + } + ArgAction::Help => () + ArgAction::Version => () + _ => matches.flags[arg.name] = true + } + matches.flag_sources[arg.name] = source +} + +///| +fn parse_bool(value : String) -> Bool raise ArgParseError { + if value == "1" || value == "true" || value == "yes" || value == "on" { + true + } else if value == "0" || value == "false" || value == "no" || value == "off" { + false + } else { + raise ArgParseError::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 ArgParseError::InvalidValue( + "invalid value '\{value}' for count; expected a non-negative integer", + ) + } noraise { + _..<0 => + raise ArgParseError::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..53e30798a --- /dev/null +++ b/argparse/pkg.generated.mbti @@ -0,0 +1,130 @@ +// Generated using `moon info`, DON'T EDIT IT +package "moonbitlang/core/argparse" + +// Values +pub fn[T : FromMatches] from_matches(Matches) -> T raise ArgParseError + +// Errors +pub suberror ArgBuildError { + Unsupported(String) +} +pub impl Show for ArgBuildError + +pub(all) suberror ArgParseError { + UnknownArgument(String, String?) + InvalidArgument(String) + MissingValue(String) + MissingRequired(String) + TooFewValues(String, Int, Int) + TooManyValues(String, Int, Int) + TooManyPositionals + InvalidValue(String) + MissingGroup(String) + GroupConflict(String) +} +pub impl Show for ArgParseError + +pub suberror DisplayHelp { + Message(String) +} +pub impl Show for DisplayHelp + +pub suberror DisplayVersion { + Message(String) +} +pub impl Show for DisplayVersion + +// Types and methods +pub struct ArgGroup { + // private fields + + fn new(String, required? : Bool, multiple? : Bool, args? : Array[String], requires? : Array[String], conflicts_with? : Array[String]) -> ArgGroup +} +pub fn ArgGroup::new(String, required? : Bool, multiple? : Bool, args? : Array[String], requires? : Array[String], conflicts_with? : Array[String]) -> Self + +pub struct Command { + // private fields + + fn new(String, args? : Array[&ArgLike], subcommands? : Array[Command], about? : String, version? : String, disable_help_flag? : Bool, disable_version_flag? : Bool, disable_help_subcommand? : Bool, arg_required_else_help? : Bool, subcommand_required? : Bool, hidden? : Bool, groups? : Array[ArgGroup]) -> Command +} +pub fn Command::new(String, args? : Array[&ArgLike], subcommands? : Array[Self], about? : String, version? : String, disable_help_flag? : Bool, disable_version_flag? : Bool, disable_help_subcommand? : Bool, arg_required_else_help? : Bool, subcommand_required? : Bool, hidden? : Bool, groups? : Array[ArgGroup]) -> Self +pub fn Command::parse(Self, argv? : Array[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(String, short? : Char, long? : String, about? : String, action? : FlagAction, env? : String, requires? : Array[String], conflicts_with? : Array[String], required? : Bool, global? : Bool, negatable? : Bool, hidden? : Bool) -> FlagArg +} +pub fn FlagArg::new(String, short? : Char, long? : String, about? : String, action? : FlagAction, env? : String, requires? : Array[String], conflicts_with? : Array[String], required? : Bool, global? : Bool, negatable? : Bool, hidden? : Bool) -> Self +pub impl ArgLike for FlagArg + +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(all) enum OptionAction { + Set + Append +} +pub impl Eq for OptionAction +pub impl Show for OptionAction + +pub struct OptionArg { + // private fields + + fn new(String, short? : Char, long? : String, about? : String, action? : OptionAction, env? : String, default_values? : Array[String], allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], required? : Bool, global? : Bool, hidden? : Bool) -> OptionArg +} +pub fn OptionArg::new(String, short? : Char, long? : String, about? : String, action? : OptionAction, env? : String, default_values? : Array[String], allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], required? : Bool, global? : Bool, hidden? : Bool) -> Self +pub impl ArgLike for OptionArg + +pub struct PositionalArg { + // private fields + + fn new(String, index? : Int, about? : String, env? : String, default_values? : Array[String], num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], required? : Bool, global? : Bool, hidden? : Bool) -> PositionalArg +} +pub fn PositionalArg::new(String, index? : Int, about? : String, env? : String, default_values? : Array[String], num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], required? : Bool, global? : Bool, hidden? : Bool) -> Self +pub impl ArgLike for PositionalArg + +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 + +// Type aliases + +// Traits +trait ArgLike + +pub(open) trait FromMatches { + from_matches(Matches) -> Self raise ArgParseError +} + diff --git a/argparse/value_range.mbt b/argparse/value_range.mbt new file mode 100644 index 000000000..4fba32977 --- /dev/null +++ b/argparse/value_range.mbt @@ -0,0 +1,39 @@ +// 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. +/// +/// Examples: +/// - `ValueRange(lower=0)` means `0..`. +/// - `ValueRange(lower=1, upper=3)` means `1..=3`. +pub fn ValueRange::new(lower? : Int = 0, upper? : Int) -> ValueRange { + ValueRange::{ lower, upper } +}