From 1922ec008731d0c682204aa45cb3a831d21a5241 Mon Sep 17 00:00:00 2001 From: zihang Date: Fri, 6 Feb 2026 17:37:33 +0800 Subject: [PATCH 1/5] feat: argparse --- argparse/README.mbt.md | 125 ++ argparse/arg_action.mbt | 88 ++ argparse/arg_group.mbt | 61 + argparse/arg_spec.mbt | 349 ++++++ argparse/argparse_blackbox_test.mbt | 1345 ++++++++++++++++++++ argparse/argparse_test.mbt | 444 +++++++ argparse/command.mbt | 285 +++++ argparse/error.mbt | 85 ++ argparse/help_render.mbt | 423 +++++++ argparse/matches.mbt | 91 ++ argparse/moon.pkg | 5 + argparse/parser.mbt | 1767 +++++++++++++++++++++++++++ argparse/pkg.generated.mbti | 131 ++ argparse/value_range.mbt | 49 + 14 files changed, 5248 insertions(+) create mode 100644 argparse/README.mbt.md create mode 100644 argparse/arg_action.mbt create mode 100644 argparse/arg_group.mbt create mode 100644 argparse/arg_spec.mbt create mode 100644 argparse/argparse_blackbox_test.mbt create mode 100644 argparse/argparse_test.mbt create mode 100644 argparse/command.mbt create mode 100644 argparse/error.mbt create mode 100644 argparse/help_render.mbt create mode 100644 argparse/matches.mbt create mode 100644 argparse/moon.pkg create mode 100644 argparse/parser.mbt create mode 100644 argparse/pkg.generated.mbti create mode 100644 argparse/value_range.mbt diff --git a/argparse/README.mbt.md b/argparse/README.mbt.md new file mode 100644 index 000000000..57502db5a --- /dev/null +++ b/argparse/README.mbt.md @@ -0,0 +1,125 @@ +# moonbitlang/core/argparse + +Declarative argument parsing for MoonBit. + +## Argument Shape Rule + +If an argument has neither `short` nor `long`, it is parsed as a positional +argument. + +This applies even for `OptionArg("name")`. For readability, prefer +`PositionalArg("name", index=...)` when you mean positional input. + +```mbt check +///| +test "name-only option behaves as positional" { + let cmd = @argparse.Command("demo", args=[@argparse.OptionArg("input")]) + let matches = cmd.parse(argv=["file.txt"], env={}) catch { _ => panic() } + assert_true(matches.values is { "input": ["file.txt"], .. }) +} +``` + +## 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..f70bbae67 --- /dev/null +++ b/argparse/arg_action.mbt @@ -0,0 +1,88 @@ +// 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 resolve_value_range(range : ValueRange) -> (Int, Int?) { + let min = match range.lower { + Some(value) => if range.lower_inclusive { value } else { value + 1 } + None => 0 + } + let max = match range.upper { + Some(value) => Some(if range.upper_inclusive { value } else { value - 1 }) + None => None + } + (min, max) +} + +///| +fn validate_value_range(range : ValueRange) -> (Int, Int?) raise ArgBuildError { + let (min, max) = resolve_value_range(range) + match max { + Some(max_value) if max_value < min => + raise ArgBuildError::Unsupported("max values must be >= min values") + _ => () + } + (min, max) +} + +///| +fn arg_min_max_for_validate(arg : Arg) -> (Int, Int?) raise ArgBuildError { + match arg.num_args { + Some(range) => validate_value_range(range) + None => (0, None) + } +} + +///| +fn arg_min_max(arg : Arg) -> (Int, Int?) { + match arg.num_args { + Some(range) => resolve_value_range(range) + None => (0, None) + } +} diff --git a/argparse/arg_group.mbt b/argparse/arg_group.mbt new file mode 100644 index 000000000..6ab3d87fa --- /dev/null +++ b/argparse/arg_group.mbt @@ -0,0 +1,61 @@ +// 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] + + fn new( + name : String, + required? : Bool, + multiple? : Bool, + args? : Array[String], + requires? : Array[String], + conflicts_with? : Array[String], + ) -> ArgGroup +} + +///| +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: clone_array_group_decl(args), + requires: clone_array_group_decl(requires), + conflicts_with: clone_array_group_decl(conflicts_with), + } +} + +///| +fn[T] clone_array_group_decl(arr : Array[T]) -> Array[T] { + let out = Array::new(capacity=arr.length()) + for value in arr { + out.push(value) + } + out +} diff --git a/argparse/arg_spec.mbt b/argparse/arg_spec.mbt new file mode 100644 index 000000000..1cbe26d49 --- /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] + group : String? + required : Bool + global : Bool + negatable : Bool + hidden : Bool +} + +///| +/// Trait for declarative arg constructors. +trait ArgLike { + to_arg(Self) -> Arg +} + +///| +/// Declarative flag constructor wrapper. +pub struct FlagArg { + priv arg : Arg + + fn new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : FlagAction, + env? : String, + requires? : Array[String], + conflicts_with? : Array[String], + group? : String, + required? : Bool, + global? : Bool, + negatable? : Bool, + hidden? : Bool, + ) -> FlagArg +} + +///| +pub impl ArgLike for FlagArg with to_arg(self : FlagArg) { + self.arg +} + +///| +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] = [], + group? : 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: clone_array_spec(requires), + conflicts_with: clone_array_spec(conflicts_with), + group, + required, + global, + negatable, + hidden, + }, + } +} + +///| +/// Declarative option constructor wrapper. +pub struct OptionArg { + priv arg : Arg + + fn new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : OptionAction, + env? : String, + default_values? : Array[String], + num_args? : ValueRange, + allow_hyphen_values? : Bool, + last? : Bool, + requires? : Array[String], + conflicts_with? : Array[String], + group? : String, + required? : Bool, + global? : Bool, + hidden? : Bool, + ) -> OptionArg +} + +///| +pub impl ArgLike for OptionArg with to_arg(self : OptionArg) { + self.arg +} + +///| +pub fn OptionArg::new( + name : String, + short? : Char, + long? : String, + about? : String, + action? : OptionAction = OptionAction::Set, + env? : String, + default_values? : Array[String], + num_args? : ValueRange, + allow_hyphen_values? : Bool = false, + last? : Bool = false, + requires? : Array[String] = [], + conflicts_with? : Array[String] = [], + group? : 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: clone_optional_array_string(default_values), + num_args, + multiple: allows_multiple_values(num_args, action), + allow_hyphen_values, + last, + requires: clone_array_spec(requires), + conflicts_with: clone_array_spec(conflicts_with), + group, + required, + global, + negatable: false, + hidden, + }, + } +} + +///| +/// Declarative positional constructor wrapper. +pub struct PositionalArg { + priv arg : Arg + + 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], + group? : String, + required? : Bool, + global? : Bool, + hidden? : Bool, + ) -> PositionalArg +} + +///| +pub impl ArgLike for PositionalArg with to_arg(self : PositionalArg) { + self.arg +} + +///| +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] = [], + group? : 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: clone_optional_array_string(default_values), + num_args, + multiple: range_allows_multiple(num_args), + allow_hyphen_values, + last, + requires: clone_array_spec(requires), + conflicts_with: clone_array_spec(conflicts_with), + group, + 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( + num_args : ValueRange?, + action : OptionAction, +) -> Bool { + action == OptionAction::Append || range_allows_multiple(num_args) +} + +///| +fn range_allows_multiple(range : ValueRange?) -> Bool { + match range { + Some(r) => { + let min = match r.lower { + Some(value) => if r.lower_inclusive { value } else { value + 1 } + None => 0 + } + let max = match r.upper { + Some(value) => Some(if r.upper_inclusive { value } else { value - 1 }) + None => None + } + if min > 1 { + true + } else { + match max { + Some(value) => value > 1 + None => true + } + } + } + None => false + } +} + +///| +fn[T] clone_array_spec(arr : Array[T]) -> Array[T] { + let out = Array::new(capacity=arr.length()) + for value in arr { + out.push(value) + } + out +} + +///| +fn clone_optional_array_string(values : Array[String]?) -> Array[String]? { + match values { + Some(arr) => Some(clone_array_spec(arr)) + None => None + } +} diff --git a/argparse/argparse_blackbox_test.mbt b/argparse/argparse_blackbox_test.mbt new file mode 100644 index 000000000..65526ff14 --- /dev/null +++ b/argparse/argparse_blackbox_test.mbt @@ -0,0 +1,1345 @@ +// 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", "path", + ]), + ], + subcommands=[ + @argparse.Command("run", about="run"), + @argparse.Command("hidden", about="hidden", hidden=true), + ], + args=[ + @argparse.FlagArg("fast", short='f', long="fast", group="mode"), + @argparse.FlagArg("slow", long="slow", group="mode", 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, + group="mode", + ), + @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...] + #| + #|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, + group="grp", + ), + @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"], + group="grp", + ), + @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"], + group="grp", + 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 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 "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 + #| + #|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 "bounded positional does not greedily consume later required values" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "first", + index=0, + num_args=@argparse.ValueRange(lower=1, upper=2), + ), + @argparse.PositionalArg("second", index=1, 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 "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", + default_values=["a", "b"], + num_args=@argparse.ValueRange(lower=0), + ), + @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", + num_args=@argparse.ValueRange(upper=2), + ), + ]) + try + upper_only.parse( + argv=["--tag", "a", "--tag", "b", "--tag", "c"], + env=empty_env(), + ) + catch { + @argparse.ArgParseError::TooManyValues(name, got, max) => { + assert_true(name == "tag") + assert_true(got == 3) + assert_true(max == 2) + } + _ => panic() + } noraise { + _ => panic() + } + + let lower_only = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + num_args=@argparse.ValueRange(lower=1), + ), + ]) + try lower_only.parse(argv=[], env=empty_env()) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "tag") + assert_true(got == 0) + assert_true(min == 1) + } + _ => panic() + } noraise { + _ => panic() + } + + let empty_range = @argparse.ValueRange::empty() + let single_range = @argparse.ValueRange::single() + inspect( + (empty_range, single_range), + content=( + #|({lower: Some(0), upper: Some(0), lower_inclusive: true, upper_inclusive: true}, {lower: Some(1), upper: Some(1), lower_inclusive: true, upper_inclusive: true}) + ), + ) +} + +///| +test "num_args options consume argv values in one occurrence" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + ]) + let parsed = cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["a", "b"], .. }) + assert_true(parsed.sources is { "tag": @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=( + #|help/version actions require short/long option + ), + ) + _ => 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() + } + + let ranged = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "x", + long="x", + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + ]).parse(argv=["--x", "a", "--x", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(ranged.values is { "x": ["a", "b"], .. }) + + 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 require action=Append or num_args allowing >1 + ), + ) + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "x", + long="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", 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() + } +} diff --git a/argparse/argparse_test.mbt b/argparse/argparse_test.mbt new file mode 100644 index 000000000..4f01465c4 --- /dev/null +++ b/argparse/argparse_test.mbt @@ -0,0 +1,444 @@ +// 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 num_args_cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + ]) + + try num_args_cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + inspect(name, content="tag") + inspect(got, content="1") + inspect(min, content="2") + } + _ => panic() + } noraise { + _ => panic() + } + + try + num_args_cmd.parse( + argv=["--tag", "a", "--tag", "b", "--tag", "c"], + env=empty_env(), + ) + catch { + @argparse.ArgParseError::TooManyValues(name, got, max) => { + inspect(name, content="tag") + inspect(got, content="3") + inspect(max, content="2") + } + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "arg groups required and multiple" { + let cmd = @argparse.Command( + "demo", + groups=[@argparse.ArgGroup("mode", required=true, multiple=false)], + args=[ + @argparse.FlagArg("fast", long="fast", group="mode"), + @argparse.FlagArg("slow", long="slow", group="mode"), + ], + ) + + 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] + #| + #|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"), + ]) + 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..07254510c --- /dev/null +++ b/argparse/command.mbt @@ -0,0 +1,285 @@ +// 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 + + 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 +} + +///| +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 { + Command::{ + name, + args: normalize_args(args), + groups: clone_array_cmd(groups), + subcommands: clone_array_cmd(subcommands), + about, + version, + disable_help_flag, + disable_version_flag, + disable_help_subcommand, + arg_required_else_help, + subcommand_required, + hidden, + } +} + +///| +fn normalize_args(args : Array[&ArgLike]) -> Array[Arg] { + let out = Array::new(capacity=args.length()) + for arg in args { + out.push(arg.to_arg()) + } + out +} + +///| +/// Render help text without parsing. +pub fn Command::render_help(self : Command) -> String { + render_help(self) +} + +///| +/// Parse argv/environment according to this command spec. +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 = concat_decl_specs(inherited_globals, cmd.args) + + for spec in specs { + let name = arg_name(spec) + match raw.values.get(name) { + Some(vs) => values[name] = clone_array_cmd(vs) + 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(vs) => highest_source(vs) + 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 = concat_decl_specs( + inherited_globals, + collect_decl_globals(cmd.args), + ) + + let subcommand = match raw.parsed_subcommand { + Some((name, sub_raw)) => + match find_decl_subcommand(cmd.subcommands, name) { + Some(sub_spec) => + Some((name, build_matches(sub_spec, sub_raw, child_globals))) + None => + 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 collect_decl_globals(args : Array[Arg]) -> Array[Arg] { + let globals = [] + for arg in args { + if arg.global && (arg.long is Some(_) || arg.short is Some(_)) { + globals.push(arg) + } + } + globals +} + +///| +fn concat_decl_specs(parent : Array[Arg], more : Array[Arg]) -> Array[Arg] { + let out = clone_array_cmd(parent) + for arg in more { + out.push(arg) + } + out +} + +///| +fn find_decl_subcommand(subs : Array[Command], name : String) -> Command? { + for sub in subs { + if sub.name == name { + return Some(sub) + } + } + None +} + +///| +fn command_args(cmd : Command) -> Array[Arg] { + let args = Array::new(capacity=cmd.args.length()) + for spec in cmd.args { + args.push(spec) + } + args +} + +///| +fn command_groups(cmd : Command) -> Array[ArgGroup] { + let groups = clone_array_cmd(cmd.groups) + for arg in cmd.args { + match arg.group { + Some(group_name) => + add_arg_to_group_membership(groups, group_name, arg_name(arg)) + None => () + } + } + groups +} + +///| +fn add_arg_to_group_membership( + groups : Array[ArgGroup], + group_name : String, + arg_name : String, +) -> Unit { + let mut idx : Int? = None + for i = 0; i < groups.length(); i = i + 1 { + if groups[i].name == group_name { + idx = Some(i) + break + } + } + match idx { + Some(i) => { + if groups[i].args.contains(arg_name) { + return + } + let args = clone_array_cmd(groups[i].args) + args.push(arg_name) + groups[i] = ArgGroup::{ ..groups[i], args, } + } + None => + groups.push(ArgGroup::{ + name: group_name, + required: false, + multiple: true, + args: [arg_name], + requires: [], + conflicts_with: [], + }) + } +} + +///| +fn[T] clone_array_cmd(arr : Array[T]) -> Array[T] { + let out = Array::new(capacity=arr.length()) + for value in arr { + out.push(value) + } + out +} 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..cbcbefbb9 --- /dev/null +++ b/argparse/help_render.mbt @@ -0,0 +1,423 @@ +// 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 = command_about(cmd) + let about_section = if about == "" { + "" + } else { + ( + $| + $| + $|\{about} + ) + } + let command_lines = subcommand_entries(cmd) + let commands_section = if command_lines.length() == 0 { + "" + } else { + let body = command_lines.join("\n") + ( + $| + $| + $|Commands: + $|\{body} + ) + } + let argument_lines = positional_entries(cmd) + let arguments_section = if argument_lines.length() == 0 { + "" + } else { + let body = argument_lines.join("\n") + ( + $| + $| + $|Arguments: + $|\{body} + ) + } + let option_lines = option_entries(cmd) + let options_section = if option_lines.length() == 0 { + ( + $| + $| + $|Options: + ) + } else { + let body = option_lines.join("\n") + ( + $| + $| + $|Options: + $|\{body} + ) + } + let group_lines = group_entries(cmd) + let groups_section = if group_lines.length() == 0 { + "" + } else { + let body = group_lines.join("\n") + ( + $| + $| + $|Groups: + $|\{body} + ) + } + ( + $|\{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]" + } + if has_subcommands_for_help(cmd) { + tail = "\{tail} " + } + let pos = positional_usage(cmd) + if pos != "" { + tail = "\{tail} \{pos}" + } + tail +} + +///| +fn has_options(cmd : Command) -> Bool { + for arg in command_args(cmd) { + if arg_hidden(arg) { + continue + } + if arg.long is Some(_) || arg.short is Some(_) { + return true + } + } + false +} + +///| +fn positional_usage(cmd : Command) -> String { + let parts = Array::new(capacity=command_args(cmd).length()) + for arg in positional_args(command_args(cmd)) { + if arg_hidden(arg) { + 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 = command_args(cmd) + 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(arg) { + 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=command_args(cmd).length()) + for arg in positional_args(command_args(cmd)) { + if arg_hidden(arg) { + 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 command_hidden(sub) { + continue + } + display.push((command_display(sub), command_about(sub))) + } + 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=command_groups(cmd).length()) + for group in command_groups(cmd) { + let members = group_members(cmd, group) + if members == "" { + continue + } + display.push((group_label(group), members)) + } + format_entries(display) +} + +///| +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 command_display(cmd : Command) -> String { + cmd.name +} + +///| +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 command_about(cmd : Command) -> String { + cmd.about.unwrap_or("") +} + +///| +fn arg_help(arg : Arg) -> String { + arg.about.unwrap_or("") +} + +///| +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_help(arg) + if help == "" { + notes.join(", ") + } else if notes.length() > 0 { + let notes_text = notes.join(", ") + "\{help} (\{notes_text})" + } else { + help + } +} + +///| +fn arg_hidden(arg : Arg) -> Bool { + arg.hidden +} + +///| +fn command_hidden(cmd : Command) -> Bool { + cmd.hidden +} + +///| +fn has_subcommands_for_help(cmd : Command) -> Bool { + if help_subcommand_enabled(cmd) { + return true + } + for sub in cmd.subcommands { + if !command_hidden(sub) { + 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 command_args(cmd) { + if arg_hidden(arg) { + 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..1ebd04108 --- /dev/null +++ b/argparse/matches.mbt @@ -0,0 +1,91 @@ +// 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, Array[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, + } +} + +///| +fn highest_source(sources : Array[ValueSource]) -> ValueSource? { + if sources.length() == 0 { + return None + } + let mut saw_env = false + let mut saw_default = false + for s in sources { + if s == ValueSource::Argv { + return Some(ValueSource::Argv) + } + if s == ValueSource::Env { + saw_env = true + } + if s == ValueSource::Default { + saw_default = true + } + } + if saw_env { + Some(ValueSource::Env) + } else if saw_default { + Some(ValueSource::Default) + } else { + 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..cd148481a --- /dev/null +++ b/argparse/moon.pkg @@ -0,0 +1,5 @@ +import { + "moonbitlang/core/builtin", + "moonbitlang/core/env", + "moonbitlang/core/strconv", +} diff --git a/argparse/parser.mbt b/argparse/parser.mbt new file mode 100644 index 000000000..5ddf4bad6 --- /dev/null +++ b/argparse/parser.mbt @@ -0,0 +1,1767 @@ +// 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 { + raise DisplayHelp::Message(text) +} + +///| +fn raise_version(text : String) -> Unit raise { + raise DisplayVersion::Message(text) +} + +///| +fn[T] raise_unknown_long( + name : String, + long_index : Map[String, Arg], +) -> T raise { + 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 { + let hint = suggest_short(short, short_index) + raise ArgParseError::UnknownArgument("-\{short}", hint) +} + +///| +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: concat_globals(inherited_globals, command_args(cmd)), + } + } + render_help(help_cmd) +} + +///| +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], +) -> Matches raise { + let args = command_args(cmd) + let groups = command_groups(cmd) + let subcommands = cmd.subcommands + validate_command(cmd, args, groups) + if cmd.arg_required_else_help && argv.length() == 0 { + raise_help(render_help_for_context(cmd, inherited_globals)) + } + let matches = new_matches_parse_state() + let globals_here = collect_globals(args) + let child_globals = concat_globals(inherited_globals, globals_here) + 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 = last_positional_index(positionals) + let mut i = 0 + while i < argv.length() { + let arg = argv[i] + if arg == "--" { + 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) + i = i + 1 + continue + } + if builtin_help_short && arg == "-h" { + raise_help(render_help_for_context(cmd, inherited_globals)) + } + if builtin_help_long && arg == "--help" { + raise_help(render_help_for_context(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) + 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_help(render_help_for_context(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) { + let value = if inline is Some(v) { + v + } else { + if i + 1 >= argv.length() { + raise ArgParseError::MissingValue("--\{name}") + } + i = i + 1 + argv[i] + } + match assign_value(matches, spec, value, ValueSource::Argv) { + Ok(_) => () + Err(e) => raise e + } + match + consume_required_option_values( + matches, + spec, + argv, + i + 1, + long_index, + short_index, + ) { + Ok(consumed) => i = i + consumed + Err(e) => raise e + } + } else { + if inline is Some(_) { + raise ArgParseError::InvalidArgument(arg) + } + match arg_action(spec) { + ArgAction::Help => + raise_help(render_help_for_context(cmd, inherited_globals)) + ArgAction::Version => raise_version(command_version(cmd)) + _ => 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_help(render_help_for_context(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) { + let value = if pos + 1 < arg.length() { + let rest0 = arg.unsafe_substring(start=pos + 1, end=arg.length()) + match rest0.strip_prefix("=") { + Some(view) => view.to_string() + None => rest0 + } + } else { + if i + 1 >= argv.length() { + raise ArgParseError::MissingValue("-\{short}") + } + i = i + 1 + argv[i] + } + match assign_value(matches, spec, value, ValueSource::Argv) { + Ok(_) => () + Err(e) => raise e + } + match + consume_required_option_values( + matches, + spec, + argv, + i + 1, + long_index, + short_index, + ) { + Ok(consumed) => i = i + consumed + Err(e) => raise e + } + break + } else { + match arg_action(spec) { + ArgAction::Help => + raise_help(render_help_for_context(cmd, inherited_globals)) + ArgAction::Version => raise_version(command_version(cmd)) + _ => apply_flag(matches, spec, ValueSource::Argv) + } + } + pos = pos + 1 + } + i = i + 1 + continue + } + if help_subcommand_enabled(cmd) && arg == "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.length() > 0 { + match find_subcommand(subcommands, arg) { + Some(sub) => { + let rest = argv[i + 1:].to_array() + let sub_matches = parse_command(sub, rest, env, child_globals) + 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) + let env_args = concat_globals(inherited_globals, args) + let parent_matches = finalize_matches( + cmd, args, groups, matches, positionals, positional_values, env_args, + env, + ) + 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) + parent_matches.parsed_subcommand = Some((sub_name, sub_m)) + } + None => () + } + return parent_matches + } + None => () + } + } + positional_values.push(arg) + i = i + 1 + } + let env_args = concat_globals(inherited_globals, args) + finalize_matches( + cmd, args, groups, matches, positionals, positional_values, env_args, env, + ) +} + +///| +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 { + match assign_positionals(matches, positionals, positional_values) { + Ok(_) => () + Err(e) => raise e + } + match apply_env(matches, env_args, env) { + Ok(_) => () + Err(e) => raise e + } + apply_defaults(matches, env_args) + validate_values(args, matches) + validate_relationships(matches, env_args) + 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 validate_command( + cmd : Command, + args : Array[Arg], + groups : Array[ArgGroup], +) -> Unit raise ArgBuildError { + validate_group_defs(groups) + validate_group_refs(args, groups) + validate_arg_defs(args) + validate_subcommand_defs(cmd.subcommands) + validate_subcommand_required_policy(cmd) + validate_help_subcommand(cmd) + validate_version_actions(cmd) + for arg in args { + validate_arg(arg) + } + for sub in cmd.subcommands { + validate_command(sub, command_args(sub), command_groups(sub)) + } +} + +///| +fn validate_arg(arg : Arg) -> Unit raise ArgBuildError { + let positional = is_positional_arg(arg) + let has_positional_only = arg.index is Some(_) || + arg.allow_hyphen_values || + arg.last + if !positional && has_positional_only { + raise ArgBuildError::Unsupported( + "positional-only settings require no short/long", + ) + } + if arg.negatable && arg_takes_value(arg) { + raise ArgBuildError::Unsupported("negatable is only supported for flags") + } + if arg_action(arg) == ArgAction::Count && arg_takes_value(arg) { + raise ArgBuildError::Unsupported("count is only supported for flags") + } + if arg_action(arg) == ArgAction::Help || arg_action(arg) == ArgAction::Version { + if arg_takes_value(arg) { + raise ArgBuildError::Unsupported("help/version actions require flags") + } + if arg.negatable { + raise ArgBuildError::Unsupported( + "help/version actions do not support negatable", + ) + } + if arg.env is Some(_) || arg.default_values is Some(_) { + raise ArgBuildError::Unsupported( + "help/version actions do not support env/defaults", + ) + } + if arg.num_args is Some(_) || arg.multiple { + raise ArgBuildError::Unsupported( + "help/version actions do not support multiple values", + ) + } + let has_option = arg.long is Some(_) || arg.short is Some(_) + if !has_option { + raise ArgBuildError::Unsupported( + "help/version actions require short/long option", + ) + } + } + if arg.num_args is Some(_) && !arg_takes_value(arg) { + raise ArgBuildError::Unsupported( + "min/max values require value-taking arguments", + ) + } + let (min, max) = arg_min_max_for_validate(arg) + let allow_multi = arg.multiple || arg_action(arg) == ArgAction::Append + if (min > 1 || (max is Some(m) && m > 1)) && !allow_multi { + raise ArgBuildError::Unsupported( + "multiple values require action=Append or num_args allowing >1", + ) + } + if arg.default_values is Some(_) && !arg_takes_value(arg) { + raise ArgBuildError::Unsupported( + "default values require value-taking arguments", + ) + } + match arg.default_values { + Some(values) if values.length() > 1 && + !arg.multiple && + arg_action(arg) != ArgAction::Append => + raise ArgBuildError::Unsupported( + "default_values require action=Append or num_args allowing >1", + ) + _ => () + } +} + +///| +fn validate_group_defs(groups : Array[ArgGroup]) -> Unit raise ArgBuildError { + let seen : Map[String, Bool] = {} + for group in groups { + if seen.get(group.name) is Some(_) { + raise ArgBuildError::Unsupported("duplicate group: \{group.name}") + } + seen[group.name] = true + } + for group in groups { + for required in group.requires { + if required == group.name { + raise ArgBuildError::Unsupported( + "group cannot require itself: \{group.name}", + ) + } + if seen.get(required) is None { + 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.get(conflict) is None { + 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 group_index : Map[String, Bool] = {} + for group in groups { + group_index[group.name] = true + } + let arg_index : Map[String, Bool] = {} + for arg in args { + arg_index[arg.name] = true + } + for arg in args { + match arg.group { + Some(name) => + if group_index.get(name) is None { + raise ArgBuildError::Unsupported("unknown group: \{name}") + } + None => () + } + } + for group in groups { + for name in group.args { + if arg_index.get(name) is None { + raise ArgBuildError::Unsupported( + "unknown group arg: \{group.name} -> \{name}", + ) + } + } + } +} + +///| +fn validate_arg_defs(args : Array[Arg]) -> Unit raise ArgBuildError { + let seen_names : Map[String, Bool] = {} + let seen_long : Map[String, Bool] = {} + let seen_short : Map[Char, Bool] = {} + for arg in args { + if seen_names.get(arg.name) is Some(_) { + raise ArgBuildError::Unsupported("duplicate arg name: \{arg.name}") + } + seen_names[arg.name] = true + for name in collect_long_names(arg) { + if seen_long.get(name) is Some(_) { + raise ArgBuildError::Unsupported("duplicate long option: --\{name}") + } + seen_long[name] = true + } + for short in collect_short_names(arg) { + if seen_short.get(short) is Some(_) { + raise ArgBuildError::Unsupported("duplicate short option: -\{short}") + } + seen_short[short] = true + } + } + 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.get(required) is None { + 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.get(conflict) is None { + 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 : Map[String, Bool] = {} + for sub in subs { + for name in collect_subcommand_names(sub) { + if seen.get(name) is Some(_) { + raise ArgBuildError::Unsupported("duplicate subcommand: \{name}") + } + seen[name] = true + } + } +} + +///| +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) { + return + } + if find_subcommand(cmd.subcommands, "help") is Some(_) { + 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 Some(_) { + return + } + for arg in command_args(cmd) { + if arg_action(arg) == ArgAction::Version { + raise ArgBuildError::Unsupported( + "version action requires command version text", + ) + } + } +} + +///| +fn validate_command_policies(cmd : Command, matches : Matches) -> Unit raise { + 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 { + if groups.length() == 0 { + return + } + let group_presence : Map[String, Int] = {} + 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_presence.get(required).unwrap_or(0) == 0 { + raise ArgParseError::MissingGroup(required) + } + } + for conflict in group.conflicts_with { + if group_presence.get(conflict).unwrap_or(0) > 0 { + raise ArgParseError::GroupConflict( + "\{group.name} conflicts with \{conflict}", + ) + } + } + } +} + +///| +fn arg_in_group(arg : Arg, group : ArgGroup) -> Bool { + let from_arg = arg.group is Some(name) && name == group.name + from_arg || group.args.contains(arg.name) +} + +///| +fn validate_values(args : Array[Arg], matches : Matches) -> Unit raise { + 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 + } + 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) + } + match max { + Some(max) if count > max => + raise ArgParseError::TooManyValues(arg.name, count, max) + _ => () + } + } +} + +///| +fn validate_relationships(matches : Matches, args : Array[Arg]) -> Unit raise { + 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.short is None && arg.long is None +} + +///| +fn assign_positionals( + matches : Matches, + positionals : Array[Arg], + values : Array[String], +) -> Result[Unit, 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 { + match + add_value( + matches, + arg.name, + values[cursor + taken], + arg, + ValueSource::Argv, + ) { + Ok(_) => () + Err(e) => return Err(e) + } + taken = taken + 1 + } + cursor = cursor + taken + continue + } + if remaining > 0 { + match + add_value(matches, arg.name, values[cursor], arg, ValueSource::Argv) { + Ok(_) => () + Err(e) => return Err(e) + } + cursor = cursor + 1 + } + } + if cursor < values.length() { + return Err(ArgParseError::TooManyPositionals) + } + Ok(()) +} + +///| +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, +) -> Result[Unit, ArgParseError] { + if arg.multiple || arg_action(arg) == ArgAction::Append { + let arr = matches.values.get(name).unwrap_or([]) + arr.push(value) + matches.values[name] = arr + let srcs = matches.value_sources.get(name).unwrap_or([]) + srcs.push(source) + matches.value_sources[name] = srcs + } else { + matches.values[name] = [value] + matches.value_sources[name] = [source] + } + Ok(()) +} + +///| +fn assign_value( + matches : Matches, + arg : Arg, + value : String, + source : ValueSource, +) -> Result[Unit, 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 => + match parse_bool(value) { + Ok(flag) => { + matches.flags[arg.name] = flag + matches.flag_sources[arg.name] = source + Ok(()) + } + Err(e) => Err(e) + } + ArgAction::SetFalse => + match parse_bool(value) { + Ok(flag) => { + matches.flags[arg.name] = !flag + matches.flag_sources[arg.name] = source + Ok(()) + } + Err(e) => Err(e) + } + ArgAction::Count => + match parse_count(value) { + Ok(count) => { + matches.counts[arg.name] = count + matches.flags[arg.name] = count > 0 + matches.flag_sources[arg.name] = source + Ok(()) + } + Err(e) => Err(e) + } + ArgAction::Help => + Err(ArgParseError::InvalidArgument("help action does not take values")) + ArgAction::Version => + Err(ArgParseError::InvalidArgument("version action does not take values")) + } +} + +///| +fn required_option_value_count(matches : Matches, arg : Arg) -> Int { + match arg.num_args { + None => 0 + Some(_) => { + let (min, _) = arg_min_max(arg) + if min <= 0 { + return 0 + } + let count = matches.values.get(arg.name).unwrap_or([]).length() + if count >= min { + 0 + } else { + min - count + } + } + } +} + +///| +fn consume_required_option_values( + matches : Matches, + arg : Arg, + argv : Array[String], + start : Int, + long_index : Map[String, Arg], + short_index : Map[Char, Arg], +) -> Result[Int, ArgParseError] { + let need = required_option_value_count(matches, arg) + if need == 0 { + return Ok(0) + } + let mut consumed = 0 + while consumed < need && start + consumed < argv.length() { + let value = argv[start + consumed] + if starts_known_option(value, long_index, short_index) { + break + } + match assign_value(matches, arg, value, ValueSource::Argv) { + Ok(_) => () + Err(e) => return Err(e) + } + consumed = consumed + 1 + } + Ok(consumed) +} + +///| +fn starts_known_option( + arg : String, + long_index : Map[String, Arg], + short_index : Map[Char, Arg], +) -> Bool { + if !arg.has_prefix("-") || arg == "-" { + return false + } + if arg.has_prefix("--") { + let (name, _) = split_long(arg) + if long_index.get(name) is Some(_) { + return true + } + if name.has_prefix("no-") { + let target = match name.strip_prefix("no-") { + Some(view) => view.to_string() + None => "" + } + match long_index.get(target) { + Some(spec) => !arg_takes_value(spec) && spec.negatable + None => false + } + } else { + false + } + } else { + match arg.get_char(1) { + Some(ch) => short_index.get(ch) is Some(_) + None => false + } + } +} + +///| +fn apply_env( + matches : Matches, + args : Array[Arg], + env : Map[String, String], +) -> Result[Unit, 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) { + match assign_value(matches, arg, value, ValueSource::Env) { + Ok(_) => () + Err(e) => return Err(e) + } + continue + } + match arg_action(arg) { + ArgAction::Count => + match parse_count(value) { + Ok(count) => { + matches.counts[name] = count + matches.flags[name] = count > 0 + matches.flag_sources[name] = ValueSource::Env + } + Err(e) => return Err(e) + } + ArgAction::SetFalse => + match parse_bool(value) { + Ok(flag) => { + matches.flags[name] = !flag + matches.flag_sources[name] = ValueSource::Env + } + Err(e) => return Err(e) + } + ArgAction::SetTrue => + match parse_bool(value) { + Ok(flag) => { + matches.flags[name] = flag + matches.flag_sources[name] = ValueSource::Env + } + Err(e) => return Err(e) + } + ArgAction::Set => + match parse_bool(value) { + Ok(flag) => { + matches.flags[name] = flag + matches.flag_sources[name] = ValueSource::Env + } + Err(e) => return Err(e) + } + ArgAction::Append => () + ArgAction::Help => () + ArgAction::Version => () + } + } + Ok(()) +} + +///| +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 collect_long_names(arg : Arg) -> Array[String] { + let names = [] + match arg.long { + Some(value) => { + names.push(value) + if arg.negatable && !arg_takes_value(arg) { + names.push("no-\{value}") + } + } + None => () + } + names +} + +///| +fn collect_short_names(arg : Arg) -> Array[Char] { + let names = [] + match arg.short { + Some(value) => names.push(value) + None => () + } + names +} + +///| +fn collect_subcommand_names(cmd : Command) -> Array[String] { + [cmd.name] +} + +///| +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) -> Result[Bool, ArgParseError] { + if value == "1" || value == "true" || value == "yes" || value == "on" { + Ok(true) + } else if value == "0" || value == "false" || value == "no" || value == "off" { + Ok(false) + } else { + Err( + ArgParseError::InvalidValue( + "invalid value '\{value}' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off", + ), + ) + } +} + +///| +fn parse_count(value : String) -> Result[Int, ArgParseError] { + let res : Result[Int, Error] = try? @strconv.parse_int(value) + match res { + Ok(v) => + if v >= 0 { + Ok(v) + } else { + Err( + ArgParseError::InvalidValue( + "invalid value '\{value}' for count; expected a non-negative integer", + ), + ) + } + Err(_) => + Err( + ArgParseError::InvalidValue( + "invalid value '\{value}' for count; expected a non-negative integer", + ), + ) + } +} + +///| +fn suggest_long(name : String, long_index : Map[String, Arg]) -> String? { + let candidates = map_string_keys(long_index) + match suggest_name(name, candidates) { + Some(best) => Some("--\{best}") + None => None + } +} + +///| +fn suggest_short(short : Char, short_index : Map[Char, Arg]) -> String? { + let candidates = map_char_keys(short_index) + let input = short.to_string() + match suggest_name(input, candidates) { + Some(best) => Some("-\{best}") + None => None + } +} + +///| +fn map_string_keys(map : Map[String, Arg]) -> Array[String] { + let keys = [] + for key, _ in map { + keys.push(key) + } + keys +} + +///| +fn map_char_keys(map : Map[Char, Arg]) -> Array[String] { + let keys = [] + for key, _ in map { + keys.push(key.to_string()) + } + keys +} + +///| +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 = string_chars(a) + let bb = string_chars(b) + 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] = min3(del, ins, sub) + j2 = j2 + 1 + } + let temp = prev + prev = curr + curr = temp + i = i + 1 + } + prev[n] +} + +///| +fn string_chars(s : String) -> Array[Char] { + let out = [] + for ch in s { + out.push(ch) + } + out +} + +///| +fn min3(a : Int, b : Int, c : Int) -> Int { + let m = if a < b { a } else { b } + if c < m { + c + } else { + m + } +} + +///| +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] { + let out = [] + for arg in args { + if arg.global && (arg.long is Some(_) || arg.short is Some(_)) { + out.push(arg) + } + } + out +} + +///| +fn concat_globals(parent : Array[Arg], more : Array[Arg]) -> Array[Arg] { + let out = clone_array(parent) + for arg in more { + out.push(arg) + } + out +} + +///| +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 source_from_values(sources : Array[ValueSource]?) -> ValueSource? { + match sources { + Some(items) if items.length() > 0 => highest_source(items) + _ => None + } +} + +///| +fn merge_globals_from_child( + parent : Matches, + child : Matches, + globals : Array[Arg], +) -> Unit { + for arg in globals { + let name = arg.name + if arg_takes_value(arg) { + let parent_vals = parent.values.get(name) + let child_vals = child.values.get(name) + let parent_srcs = parent.value_sources.get(name) + let child_srcs = 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 { + continue + } + let parent_source = source_from_values(parent_srcs) + let child_source = source_from_values(child_srcs) + 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 = [] + let merged_srcs = [] + if parent_vals is Some(pv) { + for v in pv { + merged.push(v) + } + } + if parent_srcs is Some(ps) { + for s in ps { + merged_srcs.push(s) + } + } + if child_vals is Some(cv) { + for v in cv { + merged.push(v) + } + } + if child_srcs is Some(cs) { + for s in cs { + merged_srcs.push(s) + } + } + if merged.length() > 0 { + parent.values[name] = merged + parent.value_sources[name] = merged_srcs + } + } 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] = clone_array(cv) + } + if child_srcs is Some(cs) && cs.length() > 0 { + parent.value_sources[name] = clone_array(cs) + } + } else if parent_vals is Some(pv) && pv.length() > 0 { + parent.values[name] = clone_array(pv) + if parent_srcs is Some(ps) && ps.length() > 0 { + parent.value_sources[name] = clone_array(ps) + } + } + } + } 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] = clone_array(cv) + } + if child_srcs is Some(cs) && cs.length() > 0 { + parent.value_sources[name] = clone_array(cs) + } + } else if parent_vals is Some(pv) && pv.length() > 0 { + parent.values[name] = clone_array(pv) + if parent_srcs is Some(ps) && ps.length() > 0 { + parent.value_sources[name] = clone_array(ps) + } + } + } + } else { + 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 propagate_globals_to_child( + parent : Matches, + child : Matches, + globals : Array[Arg], +) -> Unit { + for arg in globals { + let name = arg.name + if arg_takes_value(arg) { + match parent.values.get(name) { + Some(values) => { + child.values[name] = clone_array(values) + match parent.value_sources.get(name) { + Some(srcs) => child.value_sources[name] = clone_array(srcs) + 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 => () + } + } + } +} + +///| + +///| + +///| +fn positional_args(args : Array[Arg]) -> Array[Arg] { + let with_index = [] + let without_index = [] + for arg in args { + if arg.long is None && arg.short is None { + if arg.index is Some(idx) { + with_index.push((idx, arg)) + } else { + without_index.push(arg) + } + } + } + sort_positionals(with_index) + let ordered = [] + for item in with_index { + let (_, arg) = item + ordered.push(arg) + } + for arg in without_index { + ordered.push(arg) + } + ordered +} + +///| +fn last_positional_index(positionals : Array[Arg]) -> Int? { + let mut i = 0 + while i < positionals.length() { + if positionals[i].last { + return Some(i) + } + i = i + 1 + } + None +} + +///| +fn next_positional(positionals : Array[Arg], collected : Array[String]) -> Arg? { + if collected.length() < positionals.length() { + Some(positionals[collected.length()]) + } else { + 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 +} + +///| +fn sort_positionals(items : Array[(Int, Arg)]) -> Unit { + let mut i = 1 + while i < items.length() { + let key = items[i] + let mut j = i - 1 + while j >= 0 && items[j].0 > key.0 { + items[j + 1] = items[j] + if j == 0 { + j = -1 + } else { + j = j - 1 + } + } + items[j + 1] = key + i = i + 1 + } +} + +///| +fn find_subcommand(subs : Array[Command], name : String) -> Command? { + for sub in subs { + if sub.name == name { + return Some(sub) + } + } + None +} + +///| +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 { + 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}") + } + match find_subcommand(subs, name) { + Some(sub) => { + current_globals = concat_globals( + current_globals, + collect_globals(command_args(current)), + ) + current = sub + subs = sub.subcommands + } + None => + raise ArgParseError::InvalidArgument("unknown subcommand: \{name}") + } + } + (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)) + } +} + +///| +fn[T] clone_array(arr : Array[T]) -> Array[T] { + let out = Array::new(capacity=arr.length()) + for value in arr { + out.push(value) + } + out +} diff --git a/argparse/pkg.generated.mbti b/argparse/pkg.generated.mbti new file mode 100644 index 000000000..6dc9ea171 --- /dev/null +++ b/argparse/pkg.generated.mbti @@ -0,0 +1,131 @@ +// 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], group? : 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], group? : 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], num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], group? : 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], num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], group? : 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], group? : 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], group? : String, required? : Bool, global? : Bool, hidden? : Bool) -> Self +pub impl ArgLike for PositionalArg + +pub struct ValueRange { + // private fields + + fn new(lower? : Int, upper? : Int, lower_inclusive? : Bool, upper_inclusive? : Bool) -> ValueRange +} +pub fn ValueRange::empty() -> Self +pub fn ValueRange::new(lower? : Int, upper? : Int, lower_inclusive? : Bool, upper_inclusive? : Bool) -> 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..651eb27f3 --- /dev/null +++ b/argparse/value_range.mbt @@ -0,0 +1,49 @@ +// 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? + priv lower_inclusive : Bool + priv upper_inclusive : Bool + + fn new( + lower? : Int, + upper? : Int, + lower_inclusive? : Bool, + upper_inclusive? : Bool, + ) -> ValueRange +} derive(Eq, Show) + +///| +pub fn ValueRange::empty() -> ValueRange { + ValueRange(lower=0, upper=0) +} + +///| +pub fn ValueRange::single() -> ValueRange { + ValueRange(lower=1, upper=1) +} + +///| +pub fn ValueRange::new( + lower? : Int, + upper? : Int, + lower_inclusive? : Bool = true, + upper_inclusive? : Bool = true, +) -> ValueRange { + ValueRange::{ lower, upper, lower_inclusive, upper_inclusive } +} From 6482ce7e1e96aaf80fb069b310168c85f948e77e Mon Sep 17 00:00:00 2001 From: zihang Date: Tue, 10 Feb 2026 15:11:50 +0800 Subject: [PATCH 2/5] fix: clap parity --- argparse/README.mbt.md | 19 +- argparse/arg_action.mbt | 35 +- argparse/arg_group.mbt | 15 +- argparse/arg_spec.mbt | 48 +- argparse/argparse_blackbox_test.mbt | 931 +++++++++++++++++++++++++++- argparse/argparse_test.mbt | 77 ++- argparse/command.mbt | 70 +-- argparse/help_render.mbt | 146 ++--- argparse/parser.mbt | 651 ++++++++++--------- argparse/value_range.mbt | 14 +- 10 files changed, 1452 insertions(+), 554 deletions(-) diff --git a/argparse/README.mbt.md b/argparse/README.mbt.md index 57502db5a..dd493e82b 100644 --- a/argparse/README.mbt.md +++ b/argparse/README.mbt.md @@ -4,18 +4,21 @@ Declarative argument parsing for MoonBit. ## Argument Shape Rule -If an argument has neither `short` nor `long`, it is parsed as a positional -argument. - -This applies even for `OptionArg("name")`. For readability, prefer -`PositionalArg("name", index=...)` when you mean positional input. +`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 behaves as positional" { +test "name-only option is rejected" { let cmd = @argparse.Command("demo", args=[@argparse.OptionArg("input")]) - let matches = cmd.parse(argv=["file.txt"], env={}) catch { _ => panic() } - assert_true(matches.values is { "input": ["file.txt"], .. }) + try cmd.parse(argv=["file.txt"], env={}) catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="flag/option args require short/long") + _ => panic() + } noraise { + _ => panic() + } } ``` diff --git a/argparse/arg_action.mbt b/argparse/arg_action.mbt index f70bbae67..d051bbbd5 100644 --- a/argparse/arg_action.mbt +++ b/argparse/arg_action.mbt @@ -47,34 +47,17 @@ fn arg_action(arg : Arg) -> ArgAction { } } -///| -fn resolve_value_range(range : ValueRange) -> (Int, Int?) { - let min = match range.lower { - Some(value) => if range.lower_inclusive { value } else { value + 1 } - None => 0 - } - let max = match range.upper { - Some(value) => Some(if range.upper_inclusive { value } else { value - 1 }) - None => None - } - (min, max) -} - -///| -fn validate_value_range(range : ValueRange) -> (Int, Int?) raise ArgBuildError { - let (min, max) = resolve_value_range(range) - match max { - Some(max_value) if max_value < min => - raise ArgBuildError::Unsupported("max values must be >= min values") - _ => () - } - (min, max) -} - ///| fn arg_min_max_for_validate(arg : Arg) -> (Int, Int?) raise ArgBuildError { match arg.num_args { - Some(range) => validate_value_range(range) + Some(range) => { + match range.upper { + Some(max_value) if max_value < range.lower => + raise ArgBuildError::Unsupported("max values must be >= min values") + _ => () + } + (range.lower, range.upper) + } None => (0, None) } } @@ -82,7 +65,7 @@ fn arg_min_max_for_validate(arg : Arg) -> (Int, Int?) raise ArgBuildError { ///| fn arg_min_max(arg : Arg) -> (Int, Int?) { match arg.num_args { - Some(range) => resolve_value_range(range) + Some(range) => (range.lower, range.upper) None => (0, None) } } diff --git a/argparse/arg_group.mbt b/argparse/arg_group.mbt index 6ab3d87fa..f7736335c 100644 --- a/argparse/arg_group.mbt +++ b/argparse/arg_group.mbt @@ -45,17 +45,8 @@ pub fn ArgGroup::new( name, required, multiple, - args: clone_array_group_decl(args), - requires: clone_array_group_decl(requires), - conflicts_with: clone_array_group_decl(conflicts_with), + args: args.copy(), + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), } } - -///| -fn[T] clone_array_group_decl(arr : Array[T]) -> Array[T] { - let out = Array::new(capacity=arr.length()) - for value in arr { - out.push(value) - } - out -} diff --git a/argparse/arg_spec.mbt b/argparse/arg_spec.mbt index 1cbe26d49..29909fb0d 100644 --- a/argparse/arg_spec.mbt +++ b/argparse/arg_spec.mbt @@ -122,8 +122,8 @@ pub fn FlagArg::new( multiple: false, allow_hyphen_values: false, last: false, - requires: clone_array_spec(requires), - conflicts_with: clone_array_spec(conflicts_with), + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), group, required, global, @@ -194,13 +194,13 @@ pub fn OptionArg::new( flag_action: FlagAction::SetTrue, option_action: action, env, - default_values: clone_optional_array_string(default_values), + default_values: default_values.map(Array::copy), num_args, multiple: allows_multiple_values(num_args, action), allow_hyphen_values, last, - requires: clone_array_spec(requires), - conflicts_with: clone_array_spec(conflicts_with), + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), group, required, global, @@ -267,13 +267,13 @@ pub fn PositionalArg::new( flag_action: FlagAction::SetTrue, option_action: OptionAction::Set, env, - default_values: clone_optional_array_string(default_values), + default_values: default_values.map(Array::copy), num_args, multiple: range_allows_multiple(num_args), allow_hyphen_values, last, - requires: clone_array_spec(requires), - conflicts_with: clone_array_spec(conflicts_with), + requires: requires.copy(), + conflicts_with: conflicts_with.copy(), group, required, global, @@ -309,41 +309,15 @@ fn allows_multiple_values( ///| fn range_allows_multiple(range : ValueRange?) -> Bool { match range { - Some(r) => { - let min = match r.lower { - Some(value) => if r.lower_inclusive { value } else { value + 1 } - None => 0 - } - let max = match r.upper { - Some(value) => Some(if r.upper_inclusive { value } else { value - 1 }) - None => None - } - if min > 1 { + Some(r) => + if r.lower > 1 { true } else { - match max { + match r.upper { Some(value) => value > 1 None => true } } - } None => false } } - -///| -fn[T] clone_array_spec(arr : Array[T]) -> Array[T] { - let out = Array::new(capacity=arr.length()) - for value in arr { - out.push(value) - } - out -} - -///| -fn clone_optional_array_string(values : Array[String]?) -> Array[String]? { - match values { - Some(arr) => Some(clone_array_spec(arr)) - None => None - } -} diff --git a/argparse/argparse_blackbox_test.mbt b/argparse/argparse_blackbox_test.mbt index 65526ff14..b1a1325e2 100644 --- a/argparse/argparse_blackbox_test.mbt +++ b/argparse/argparse_blackbox_test.mbt @@ -683,6 +683,22 @@ test "positionals force mode and dash handling" { } } +///| +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=[ @@ -801,7 +817,7 @@ test "defaults and value range helpers through public API" { "mode", long="mode", default_values=["a", "b"], - num_args=@argparse.ValueRange(lower=0), + num_args=@argparse.ValueRange(lower=1), ), @argparse.OptionArg("one", long="one", default_values=["x"]), ]) @@ -822,24 +838,17 @@ test "defaults and value range helpers through public API" { @argparse.OptionArg( "tag", long="tag", - num_args=@argparse.ValueRange(upper=2), + action=@argparse.OptionAction::Append, + num_args=@argparse.ValueRange(lower=1, upper=2), ), ]) - try - upper_only.parse( - argv=["--tag", "a", "--tag", "b", "--tag", "c"], - env=empty_env(), - ) - catch { - @argparse.ArgParseError::TooManyValues(name, got, max) => { - assert_true(name == "tag") - assert_true(got == 3) - assert_true(max == 2) - } - _ => panic() - } noraise { + 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( @@ -848,12 +857,13 @@ test "defaults and value range helpers through public API" { num_args=@argparse.ValueRange(lower=1), ), ]) - try lower_only.parse(argv=[], env=empty_env()) catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - assert_true(name == "tag") - assert_true(got == 0) - assert_true(min == 1) - } + 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() @@ -864,7 +874,7 @@ test "defaults and value range helpers through public API" { inspect( (empty_range, single_range), content=( - #|({lower: Some(0), upper: Some(0), lower_inclusive: true, upper_inclusive: true}, {lower: Some(1), upper: Some(1), lower_inclusive: true, upper_inclusive: true}) + #|({lower: 0, upper: Some(0)}, {lower: 1, upper: Some(1)}) ), ) } @@ -885,6 +895,164 @@ test "num_args options consume argv values in one occurrence" { assert_true(parsed.sources is { "tag": @argparse.ValueSource::Argv, .. }) } +///| +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 "num_args range option consumes optional extra argv value" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "arg", + long="arg", + num_args=@argparse.ValueRange(lower=1, upper=2), + ), + ]) + let parsed = cmd.parse(argv=["--arg", "x", "y"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "arg": ["x", "y"], .. }) + assert_true(parsed.sources is { "arg": @argparse.ValueSource::Argv, .. }) +} + +///| +test "num_args range option stops at the next option token" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "arg", + short='a', + long="arg", + num_args=@argparse.ValueRange(lower=1, upper=2), + ), + @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, .. }) + + let inline = cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(inline.values is { "arg": ["x", "y"], .. }) + assert_true(inline.flags is { "verbose": true, .. }) + + let short_inline = cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_inline.values is { "arg": ["x", "y"], .. }) + assert_true(short_inline.flags is { "verbose": true, .. }) +} + +///| +test "option num_args cannot be flag-like" { + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "opt", + long="opt", + num_args=@argparse.ValueRange(lower=0, upper=1), + ), + @argparse.FlagArg("verbose", long="verbose"), + ]).parse(argv=["--opt", "--verbose"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="option args require at least one value") + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "opt", + long="opt", + required=true, + num_args=@argparse.ValueRange(lower=0, upper=0), + ), + ]).parse(argv=["--opt"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="option args require at least one value") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +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=[ @@ -939,7 +1107,7 @@ test "validation branches exposed through parse" { inspect( msg, content=( - #|help/version actions require short/long option + #|flag/option args require short/long ), ) _ => panic() @@ -991,16 +1159,24 @@ test "validation branches exposed through parse" { _ => panic() } - let ranged = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "x", - long="x", - num_args=@argparse.ValueRange(lower=2, upper=2), - ), - ]).parse(argv=["--x", "a", "--x", "b"], env=empty_env()) catch { + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "x", + long="x", + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + ]).parse(argv=["--x", "a", "--x", "b"], env=empty_env()) + catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "x") + assert_true(got == 1) + assert_true(min == 2) + } + _ => panic() + } noraise { _ => panic() } - assert_true(ranged.values is { "x": ["a", "b"], .. }) try @argparse.Command("demo", args=[ @@ -1343,3 +1519,696 @@ test "validation branches exposed through parse" { _ => 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 assignment auto-creates missing group definition" { + let cmd = @argparse.Command("demo", groups=[@argparse.ArgGroup("known")], args=[ + @argparse.FlagArg("x", long="x", group="missing"), + ]) + 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"), @argparse.ArgGroup("right")], + args=[ + @argparse.FlagArg("l", long="left", group="left"), + @argparse.FlagArg("r", long="right", group="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, group="dyn"), + ]) + 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 ")) +} + +///| +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 "range constructors with open lower bound still validate shape rules" { + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + num_args=@argparse.ValueRange::new(upper=2), + ), + ]).parse(argv=["--tag", "x"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="option args require at least one value") + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "short option with bounded values reports per occurrence too few values" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "x", + short='x', + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + @argparse.FlagArg("verbose", short='v'), + ]) + try cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "x") + assert_true(got == 1) + assert_true(min == 2) + } + _ => 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 "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", + num_args=@argparse.ValueRange(lower=2, upper=3), + ), + ]) + try env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "pair") + assert_true(got == 1) + assert_true(min == 2) + } + _ => panic() + } noraise { + _ => panic() + } +} + +///| +test "positionals hit balancing branches and explicit index sorting" { + let cmd = @argparse.Command("demo", args=[ + @argparse.PositionalArg( + "first", + index=0, + num_args=@argparse.ValueRange(lower=2, upper=3), + ), + @argparse.PositionalArg("late", index=2, required=true), + @argparse.PositionalArg( + "mid", + index=1, + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + ]) + + try cmd.parse(argv=["a"], env=empty_env()) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "first") + assert_true(got == 1) + 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", + index=0, + num_args=@argparse.ValueRange(lower=0, upper=2), + ), + @argparse.PositionalArg("tail", index=1), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "items": ["a", "b"], "tail": ["c"], .. }) +} + +///| +test "open upper range options consume option-like values with allow_hyphen_values" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "arg", + long="arg", + allow_hyphen_values=true, + num_args=@argparse.ValueRange(lower=1), + ), + @argparse.FlagArg("verbose", long="verbose"), + @argparse.FlagArg("cache", long="cache", negatable=true), + @argparse.FlagArg("quiet", short='q'), + ]) + + let known_long = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_long.values is { "arg": ["x", "--verbose"], .. }) + assert_true(known_long.flags is { "verbose"? : None, .. }) + + let negated = cmd.parse(argv=["--arg", "x", "--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(negated.values is { "arg": ["x", "--no-cache"], .. }) + assert_true(negated.flags is { "cache"? : None, .. }) + + let unknown_long_value = cmd.parse( + argv=["--arg", "x", "--mystery"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(unknown_long_value.values is { "arg": ["x", "--mystery"], .. }) + + let known_short = cmd.parse(argv=["--arg", "x", "-q"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_short.values is { "arg": ["x", "-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, + num_args=@argparse.ValueRange(lower=1), + ), + @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", "--", "tail"], "rest"? : None, .. }, + ) +} + +///| +test "fixed upper range avoids consuming additional option values" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "one", + long="one", + num_args=@argparse.ValueRange(lower=1, upper=1), + ), + @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 "bounded long options report too few values when next token is another option" { + let cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "arg", + long="arg", + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + @argparse.FlagArg("verbose", long="verbose"), + ]) + + let ok = cmd.parse(argv=["--arg", "x", "y", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "arg": ["x", "y"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + assert_true(name == "arg") + assert_true(got == 1) + assert_true(min == 2) + } + _ => 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 local arg with global name does not update 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")]), + ], + ) + + let parsed = cmd.parse(argv=["run", "--mode", "fast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["safe"], .. }) + assert_true(parsed.sources is { "mode": @argparse.ValueSource::Default, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": @argparse.ValueSource::Argv, .. }, + ) +} + +///| +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 index 4f01465c4..4f72c1549 100644 --- a/argparse/argparse_test.mbt +++ b/argparse/argparse_test.mbt @@ -123,15 +123,86 @@ test "relationships and num args" { env=empty_env(), ) catch { - @argparse.ArgParseError::TooManyValues(name, got, max) => { + @argparse.ArgParseError::TooFewValues(name, got, min) => { inspect(name, content="tag") - inspect(got, content="3") - inspect(max, content="2") + inspect(got, content="1") + inspect(min, content="2") } _ => panic() } noraise { _ => panic() } + + let append_num_args_cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::Append, + num_args=@argparse.ValueRange(lower=1, upper=2), + ), + ]) + let appended = append_num_args_cmd.parse( + argv=["--tag", "a", "--tag", "b", "--tag", "c"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) + + let append_fixed_cmd = @argparse.Command("demo", args=[ + @argparse.OptionArg( + "tag", + long="tag", + action=@argparse.OptionAction::Append, + num_args=@argparse.ValueRange(lower=2, upper=2), + ), + ]) + try + append_fixed_cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) + catch { + @argparse.ArgParseError::TooFewValues(name, got, min) => { + inspect(name, content="tag") + inspect(got, content="1") + inspect(min, content="2") + } + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "opt", + long="opt", + num_args=@argparse.ValueRange(lower=0, upper=1), + ), + @argparse.FlagArg("verbose", long="verbose"), + ]).parse(argv=["--opt", "--verbose"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="option args require at least one value") + _ => panic() + } noraise { + _ => panic() + } + + try + @argparse.Command("demo", args=[ + @argparse.OptionArg( + "opt", + long="opt", + num_args=@argparse.ValueRange(lower=0, upper=0), + required=true, + ), + ]).parse(argv=["--opt"], env=empty_env()) + catch { + @argparse.ArgBuildError::Unsupported(msg) => + inspect(msg, content="option args require at least one value") + _ => panic() + } noraise { + _ => panic() + } } ///| diff --git a/argparse/command.mbt b/argparse/command.mbt index 07254510c..523c4d2a6 100644 --- a/argparse/command.mbt +++ b/argparse/command.mbt @@ -61,9 +61,9 @@ pub fn Command::new( ) -> Command { Command::{ name, - args: normalize_args(args), - groups: clone_array_cmd(groups), - subcommands: clone_array_cmd(subcommands), + args: args.map(x => x.to_arg()), + groups: groups.copy(), + subcommands: subcommands.copy(), about, version, disable_help_flag, @@ -75,15 +75,6 @@ pub fn Command::new( } } -///| -fn normalize_args(args : Array[&ArgLike]) -> Array[Arg] { - let out = Array::new(capacity=args.length()) - for arg in args { - out.push(arg.to_arg()) - } - out -} - ///| /// Render help text without parsing. pub fn Command::render_help(self : Command) -> String { @@ -111,12 +102,12 @@ fn build_matches( let values : Map[String, Array[String]] = {} let flag_counts : Map[String, Int] = {} let sources : Map[String, ValueSource] = {} - let specs = concat_decl_specs(inherited_globals, cmd.args) + let specs = inherited_globals + cmd.args for spec in specs { let name = arg_name(spec) match raw.values.get(name) { - Some(vs) => values[name] = clone_array_cmd(vs) + Some(vs) => values[name] = vs.copy() None => () } let count = raw.counts.get(name).unwrap_or(0) @@ -145,10 +136,10 @@ fn build_matches( None => () } } - let child_globals = concat_decl_specs( - inherited_globals, - collect_decl_globals(cmd.args), - ) + 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)) => @@ -189,26 +180,6 @@ fn build_matches( } } -///| -fn collect_decl_globals(args : Array[Arg]) -> Array[Arg] { - let globals = [] - for arg in args { - if arg.global && (arg.long is Some(_) || arg.short is Some(_)) { - globals.push(arg) - } - } - globals -} - -///| -fn concat_decl_specs(parent : Array[Arg], more : Array[Arg]) -> Array[Arg] { - let out = clone_array_cmd(parent) - for arg in more { - out.push(arg) - } - out -} - ///| fn find_decl_subcommand(subs : Array[Command], name : String) -> Command? { for sub in subs { @@ -219,18 +190,9 @@ fn find_decl_subcommand(subs : Array[Command], name : String) -> Command? { None } -///| -fn command_args(cmd : Command) -> Array[Arg] { - let args = Array::new(capacity=cmd.args.length()) - for spec in cmd.args { - args.push(spec) - } - args -} - ///| fn command_groups(cmd : Command) -> Array[ArgGroup] { - let groups = clone_array_cmd(cmd.groups) + let groups = cmd.groups.copy() for arg in cmd.args { match arg.group { Some(group_name) => @@ -259,8 +221,7 @@ fn add_arg_to_group_membership( if groups[i].args.contains(arg_name) { return } - let args = clone_array_cmd(groups[i].args) - args.push(arg_name) + let args = [..groups[i].args, arg_name] groups[i] = ArgGroup::{ ..groups[i], args, } } None => @@ -274,12 +235,3 @@ fn add_arg_to_group_membership( }) } } - -///| -fn[T] clone_array_cmd(arr : Array[T]) -> Array[T] { - let out = Array::new(capacity=arr.length()) - for value in arr { - out.push(value) - } - out -} diff --git a/argparse/help_render.mbt b/argparse/help_render.mbt index cbcbefbb9..31a43fb62 100644 --- a/argparse/help_render.mbt +++ b/argparse/help_render.mbt @@ -16,7 +16,7 @@ /// 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 = command_about(cmd) + let about = cmd.about.unwrap_or("") let about_section = if about == "" { "" } else { @@ -26,58 +26,14 @@ fn render_help(cmd : Command) -> String { $|\{about} ) } - let command_lines = subcommand_entries(cmd) - let commands_section = if command_lines.length() == 0 { - "" - } else { - let body = command_lines.join("\n") - ( - $| - $| - $|Commands: - $|\{body} - ) - } - let argument_lines = positional_entries(cmd) - let arguments_section = if argument_lines.length() == 0 { - "" - } else { - let body = argument_lines.join("\n") - ( - $| - $| - $|Arguments: - $|\{body} - ) - } - let option_lines = option_entries(cmd) - let options_section = if option_lines.length() == 0 { - ( - $| - $| - $|Options: - ) - } else { - let body = option_lines.join("\n") - ( - $| - $| - $|Options: - $|\{body} - ) - } - let group_lines = group_entries(cmd) - let groups_section = if group_lines.length() == 0 { - "" - } else { - let body = group_lines.join("\n") - ( - $| - $| - $|Groups: - $|\{body} - ) - } + 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} $| @@ -102,8 +58,8 @@ fn usage_tail(cmd : Command) -> String { ///| fn has_options(cmd : Command) -> Bool { - for arg in command_args(cmd) { - if arg_hidden(arg) { + for arg in cmd.args { + if arg.hidden { continue } if arg.long is Some(_) || arg.short is Some(_) { @@ -115,9 +71,9 @@ fn has_options(cmd : Command) -> Bool { ///| fn positional_usage(cmd : Command) -> String { - let parts = Array::new(capacity=command_args(cmd).length()) - for arg in positional_args(command_args(cmd)) { - if arg_hidden(arg) { + 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) @@ -138,7 +94,7 @@ fn positional_usage(cmd : Command) -> String { ///| fn option_entries(cmd : Command) -> Array[String] { - let args = command_args(cmd) + 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') @@ -164,7 +120,7 @@ fn option_entries(cmd : Command) -> Array[String] { if arg.long is None && arg.short is None { continue } - if arg_hidden(arg) { + if arg.hidden { continue } let name = if arg_takes_value(arg) { @@ -217,9 +173,9 @@ fn builtin_option_label( ///| fn positional_entries(cmd : Command) -> Array[String] { - let display = Array::new(capacity=command_args(cmd).length()) - for arg in positional_args(command_args(cmd)) { - if arg_hidden(arg) { + 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))) @@ -231,10 +187,10 @@ fn positional_entries(cmd : Command) -> Array[String] { fn subcommand_entries(cmd : Command) -> Array[String] { let display = Array::new(capacity=cmd.subcommands.length() + 1) for sub in cmd.subcommands { - if command_hidden(sub) { + if sub.hidden { continue } - display.push((command_display(sub), command_about(sub))) + display.push((sub.name, sub.about.unwrap_or(""))) } if help_subcommand_enabled(cmd) { display.push(("help", "Print help for the subcommand(s).")) @@ -255,6 +211,33 @@ fn group_entries(cmd : Command) -> Array[String] { 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()) @@ -273,11 +256,6 @@ fn format_entries(display : Array[(String, String)]) -> Array[String] { entries } -///| -fn command_display(cmd : Command) -> String { - cmd.name -} - ///| fn arg_display(arg : Arg) -> String { let parts = Array::new(capacity=2) @@ -307,16 +285,6 @@ fn positional_display(arg : Arg) -> String { } } -///| -fn command_about(cmd : Command) -> String { - cmd.about.unwrap_or("") -} - -///| -fn arg_help(arg : Arg) -> String { - arg.about.unwrap_or("") -} - ///| fn arg_doc(arg : Arg) -> String { let notes = [] @@ -335,7 +303,7 @@ fn arg_doc(arg : Arg) -> String { if is_required_arg(arg) { notes.push("required") } - let help = arg_help(arg) + let help = arg.about.unwrap_or("") if help == "" { notes.join(", ") } else if notes.length() > 0 { @@ -346,23 +314,13 @@ fn arg_doc(arg : Arg) -> String { } } -///| -fn arg_hidden(arg : Arg) -> Bool { - arg.hidden -} - -///| -fn command_hidden(cmd : Command) -> Bool { - cmd.hidden -} - ///| fn has_subcommands_for_help(cmd : Command) -> Bool { if help_subcommand_enabled(cmd) { return true } for sub in cmd.subcommands { - if !command_hidden(sub) { + if !sub.hidden { return true } } @@ -399,8 +357,8 @@ fn group_label(group : ArgGroup) -> String { ///| fn group_members(cmd : Command, group : ArgGroup) -> String { let members = [] - for arg in command_args(cmd) { - if arg_hidden(arg) { + for arg in cmd.args { + if arg.hidden { continue } if arg_in_group(arg, group) { diff --git a/argparse/parser.mbt b/argparse/parser.mbt index 5ddf4bad6..acecded95 100644 --- a/argparse/parser.mbt +++ b/argparse/parser.mbt @@ -48,14 +48,19 @@ fn render_help_for_context( let help_cmd = if inherited_globals.length() == 0 { cmd } else { - Command::{ - ..cmd, - args: concat_globals(inherited_globals, command_args(cmd)), - } + Command::{ ..cmd, args: inherited_globals + cmd.args } } render_help(help_cmd) } +///| +fn raise_context_help( + cmd : Command, + inherited_globals : Array[Arg], +) -> Unit raise { + raise_help(render_help_for_context(cmd, inherited_globals)) +} + ///| fn default_argv() -> Array[String] { let args = @env.args() @@ -73,16 +78,16 @@ fn parse_command( env : Map[String, String], inherited_globals : Array[Arg], ) -> Matches raise { - let args = command_args(cmd) + let args = cmd.args let groups = command_groups(cmd) let subcommands = cmd.subcommands validate_command(cmd, args, groups) if cmd.arg_required_else_help && argv.length() == 0 { - raise_help(render_help_for_context(cmd, inherited_globals)) + raise_context_help(cmd, inherited_globals) } let matches = new_matches_parse_state() let globals_here = collect_globals(args) - let child_globals = concat_globals(inherited_globals, globals_here) + let child_globals = inherited_globals + globals_here 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) && @@ -115,10 +120,10 @@ fn parse_command( continue } if builtin_help_short && arg == "-h" { - raise_help(render_help_for_context(cmd, inherited_globals)) + raise_context_help(cmd, inherited_globals) } if builtin_help_long && arg == "--help" { - raise_help(render_help_for_context(cmd, inherited_globals)) + raise_context_help(cmd, inherited_globals) } if builtin_version_short && arg == "-V" { raise_version(command_version(cmd)) @@ -139,7 +144,7 @@ fn parse_command( if inline is Some(_) { raise ArgParseError::InvalidArgument(arg) } - raise_help(render_help_for_context(cmd, inherited_globals)) + raise_context_help(cmd, inherited_globals) } if builtin_version_long && name == "version" { if inline is Some(_) { @@ -180,38 +185,56 @@ fn parse_command( } Some(spec) => if arg_takes_value(spec) { - let value = if inline is Some(v) { - v + check_duplicate_set_occurrence(matches, spec) + let min_values = option_occurrence_min(spec) + let accepts_values = option_accepts_values(spec) + let mut values_start = i + 1 + let mut consumed_first = false + if inline is Some(v) { + if !accepts_values { + raise ArgParseError::InvalidArgument(arg) + } + assign_value(matches, spec, v, ValueSource::Argv) + consumed_first = true } else { - if i + 1 >= argv.length() { + let can_take_next = i + 1 < argv.length() && + !should_stop_option_value( + argv[i + 1], + spec, + long_index, + short_index, + ) + if can_take_next && accepts_values { + i = i + 1 + assign_value(matches, spec, argv[i], ValueSource::Argv) + values_start = i + 1 + consumed_first = true + } else if min_values > 0 { raise ArgParseError::MissingValue("--\{name}") + } else { + mark_option_present(matches, spec, ValueSource::Argv) } - i = i + 1 - argv[i] } - match assign_value(matches, spec, value, ValueSource::Argv) { - Ok(_) => () - Err(e) => raise e - } - match - consume_required_option_values( - matches, - spec, - argv, - i + 1, - long_index, - short_index, - ) { - Ok(consumed) => i = i + consumed - Err(e) => raise e + if consumed_first { + let consumed_more = consume_additional_option_values( + matches, spec, argv, values_start, long_index, short_index, + ) + i = i + consumed_more + let occurrence_values = 1 + consumed_more + if occurrence_values < min_values { + raise ArgParseError::TooFewValues( + spec.name, + occurrence_values, + min_values, + ) + } } } else { if inline is Some(_) { raise ArgParseError::InvalidArgument(arg) } match arg_action(spec) { - ArgAction::Help => - raise_help(render_help_for_context(cmd, inherited_globals)) + ArgAction::Help => raise_context_help(cmd, inherited_globals) ArgAction::Version => raise_version(command_version(cmd)) _ => apply_flag(matches, spec, ValueSource::Argv) } @@ -226,7 +249,7 @@ fn parse_command( while pos < arg.length() { let short = arg.get_char(pos).unwrap() if short == 'h' && builtin_help_short { - raise_help(render_help_for_context(cmd, inherited_globals)) + raise_context_help(cmd, inherited_globals) } if short == 'V' && builtin_version_short { raise_version(command_version(cmd)) @@ -236,40 +259,59 @@ fn parse_command( None => raise_unknown_short(short, short_index) } if arg_takes_value(spec) { - let value = if pos + 1 < arg.length() { - let rest0 = arg.unsafe_substring(start=pos + 1, end=arg.length()) - match rest0.strip_prefix("=") { + check_duplicate_set_occurrence(matches, spec) + let min_values = option_occurrence_min(spec) + let accepts_values = option_accepts_values(spec) + let mut values_start = i + 1 + let mut consumed_first = false + 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 => rest0 + None => rest + } + if !accepts_values { + raise ArgParseError::InvalidArgument(arg) } + assign_value(matches, spec, inline, ValueSource::Argv) + consumed_first = true } else { - if i + 1 >= argv.length() { + let can_take_next = i + 1 < argv.length() && + !should_stop_option_value( + argv[i + 1], + spec, + long_index, + short_index, + ) + if can_take_next && accepts_values { + i = i + 1 + assign_value(matches, spec, argv[i], ValueSource::Argv) + values_start = i + 1 + consumed_first = true + } else if min_values > 0 { raise ArgParseError::MissingValue("-\{short}") + } else { + mark_option_present(matches, spec, ValueSource::Argv) } - i = i + 1 - argv[i] } - match assign_value(matches, spec, value, ValueSource::Argv) { - Ok(_) => () - Err(e) => raise e - } - match - consume_required_option_values( - matches, - spec, - argv, - i + 1, - long_index, - short_index, - ) { - Ok(consumed) => i = i + consumed - Err(e) => raise e + if consumed_first { + let consumed_more = consume_additional_option_values( + matches, spec, argv, values_start, long_index, short_index, + ) + i = i + consumed_more + let occurrence_values = 1 + consumed_more + if occurrence_values < min_values { + raise ArgParseError::TooFewValues( + spec.name, + occurrence_values, + min_values, + ) + } } break } else { match arg_action(spec) { - ArgAction::Help => - raise_help(render_help_for_context(cmd, inherited_globals)) + ArgAction::Help => raise_context_help(cmd, inherited_globals) ArgAction::Version => raise_version(command_version(cmd)) _ => apply_flag(matches, spec, ValueSource::Argv) } @@ -292,11 +334,14 @@ fn parse_command( Some(sub) => { let rest = argv[i + 1:].to_array() let sub_matches = parse_command(sub, rest, env, child_globals) + 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) - let env_args = concat_globals(inherited_globals, args) + 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, @@ -304,7 +349,9 @@ fn parse_command( 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) + propagate_globals_to_child( + parent_matches, sub_m, child_globals, child_local_non_globals, + ) parent_matches.parsed_subcommand = Some((sub_name, sub_m)) } None => () @@ -317,7 +364,7 @@ fn parse_command( positional_values.push(arg) i = i + 1 } - let env_args = concat_globals(inherited_globals, args) + let env_args = inherited_globals + args finalize_matches( cmd, args, groups, matches, positionals, positional_values, env_args, env, ) @@ -334,14 +381,8 @@ fn finalize_matches( env_args : Array[Arg], env : Map[String, String], ) -> Matches raise { - match assign_positionals(matches, positionals, positional_values) { - Ok(_) => () - Err(e) => raise e - } - match apply_env(matches, env_args, env) { - Ok(_) => () - Err(e) => raise e - } + assign_positionals(matches, positionals, positional_values) + apply_env(matches, env_args, env) apply_defaults(matches, env_args) validate_values(args, matches) validate_relationships(matches, env_args) @@ -387,16 +428,23 @@ fn validate_command( validate_arg(arg) } for sub in cmd.subcommands { - validate_command(sub, command_args(sub), command_groups(sub)) + validate_command(sub, sub.args, command_groups(sub)) } } ///| fn validate_arg(arg : Arg) -> Unit raise ArgBuildError { let positional = is_positional_arg(arg) - let has_positional_only = arg.index is Some(_) || - arg.allow_hyphen_values || - arg.last + let has_option_name = arg.long is Some(_) || arg.short is Some(_) + if positional && has_option_name { + raise ArgBuildError::Unsupported( + "positional args do not support short/long", + ) + } + if !positional && !has_option_name { + raise ArgBuildError::Unsupported("flag/option args require short/long") + } + let has_positional_only = arg.index is Some(_) || arg.last if !positional && has_positional_only { raise ArgBuildError::Unsupported( "positional-only settings require no short/long", @@ -440,6 +488,9 @@ fn validate_arg(arg : Arg) -> Unit raise ArgBuildError { ) } let (min, max) = arg_min_max_for_validate(arg) + if !positional && arg_takes_value(arg) && arg.num_args is Some(_) && min == 0 { + raise ArgBuildError::Unsupported("option args require at least one value") + } let allow_multi = arg.multiple || arg_action(arg) == ArgAction::Append if (min > 1 || (max is Some(m) && m > 1)) && !allow_multi { raise ArgBuildError::Unsupported( @@ -630,7 +681,7 @@ fn validate_version_actions(cmd : Command) -> Unit raise ArgBuildError { if cmd.version is Some(_) { return } - for arg in command_args(cmd) { + for arg in cmd.args { if arg_action(arg) == ArgAction::Version { raise ArgBuildError::Unsupported( "version action requires command version text", @@ -712,16 +763,21 @@ fn validate_values(args : Array[Arg], matches : Matches) -> Unit raise { if !arg_takes_value(arg) { continue } + if !present { + 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) } - match max { - Some(max) if count > max => - raise ArgParseError::TooManyValues(arg.name, count, max) - _ => () + if arg_action(arg) != ArgAction::Append { + match max { + Some(max) if count > max => + raise ArgParseError::TooManyValues(arg.name, count, max) + _ => () + } } } } @@ -749,7 +805,7 @@ fn validate_relationships(matches : Matches, args : Array[Arg]) -> Unit raise { ///| fn is_positional_arg(arg : Arg) -> Bool { - arg.short is None && arg.long is None + arg.is_positional } ///| @@ -757,7 +813,7 @@ fn assign_positionals( matches : Matches, positionals : Array[Arg], values : Array[String], -) -> Result[Unit, ArgParseError] { +) -> Unit raise ArgParseError { let mut cursor = 0 for idx in 0.. () - Err(e) => return Err(e) - } + add_value( + matches, + arg.name, + values[cursor + taken], + arg, + ValueSource::Argv, + ) taken = taken + 1 } cursor = cursor + taken continue } if remaining > 0 { - match - add_value(matches, arg.name, values[cursor], arg, ValueSource::Argv) { - Ok(_) => () - Err(e) => return Err(e) - } + add_value(matches, arg.name, values[cursor], arg, ValueSource::Argv) cursor = cursor + 1 } } if cursor < values.length() { - return Err(ArgParseError::TooManyPositionals) + raise ArgParseError::TooManyPositionals } - Ok(()) } ///| @@ -842,7 +889,7 @@ fn add_value( value : String, arg : Arg, source : ValueSource, -) -> Result[Unit, ArgParseError] { +) -> Unit { if arg.multiple || arg_action(arg) == ArgAction::Append { let arr = matches.values.get(name).unwrap_or([]) arr.push(value) @@ -854,7 +901,6 @@ fn add_value( matches.values[name] = [value] matches.value_sources[name] = [source] } - Ok(()) } ///| @@ -863,124 +909,167 @@ fn assign_value( arg : Arg, value : String, source : ValueSource, -) -> Result[Unit, ArgParseError] { +) -> 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 => - match parse_bool(value) { - Ok(flag) => { - matches.flags[arg.name] = flag - matches.flag_sources[arg.name] = source - Ok(()) - } - Err(e) => Err(e) - } - ArgAction::SetFalse => - match parse_bool(value) { - Ok(flag) => { - matches.flags[arg.name] = !flag - matches.flag_sources[arg.name] = source - Ok(()) - } - Err(e) => Err(e) - } - ArgAction::Count => - match parse_count(value) { - Ok(count) => { - matches.counts[arg.name] = count - matches.flags[arg.name] = count > 0 - matches.flag_sources[arg.name] = source - Ok(()) - } - Err(e) => Err(e) - } + 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 => - Err(ArgParseError::InvalidArgument("help action does not take values")) + raise ArgParseError::InvalidArgument("help action does not take values") ArgAction::Version => - Err(ArgParseError::InvalidArgument("version action does not take values")) + raise ArgParseError::InvalidArgument( + "version action does not take values", + ) } } ///| -fn required_option_value_count(matches : Matches, arg : Arg) -> Int { +fn option_occurrence_min(arg : Arg) -> Int { match arg.num_args { - None => 0 Some(_) => { let (min, _) = arg_min_max(arg) - if min <= 0 { - return 0 + min + } + None => 1 + } +} + +///| +fn option_accepts_values(arg : Arg) -> Bool { + match arg.num_args { + Some(_) => { + let (_, max) = arg_min_max(arg) + match max { + Some(max_count) => max_count > 0 + None => true } - let count = matches.values.get(arg.name).unwrap_or([]).length() - if count >= min { - 0 - } else { - min - count + } + None => true + } +} + +///| +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 mark_option_present( + matches : Matches, + arg : Arg, + source : ValueSource, +) -> Unit { + if matches.values.get(arg.name) is None { + matches.values[arg.name] = [] + } + let srcs = matches.value_sources.get(arg.name).unwrap_or([]) + srcs.push(source) + matches.value_sources[arg.name] = srcs +} + +///| +fn required_option_value_count(arg : Arg) -> Int { + match arg.num_args { + None => 0 + Some(_) => { + let (_, max) = arg_min_max(arg) + match max { + Some(max_count) if max_count <= 1 => 0 + Some(max_count) => max_count - 1 + None => -1 } } } } ///| -fn consume_required_option_values( +fn consume_additional_option_values( matches : Matches, arg : Arg, argv : Array[String], start : Int, long_index : Map[String, Arg], short_index : Map[Char, Arg], -) -> Result[Int, ArgParseError] { - let need = required_option_value_count(matches, arg) - if need == 0 { - return Ok(0) +) -> Int raise ArgParseError { + let max_more = required_option_value_count(arg) + if max_more == 0 { + return 0 } let mut consumed = 0 - while consumed < need && start + consumed < argv.length() { - let value = argv[start + consumed] - if starts_known_option(value, long_index, short_index) { + while start + consumed < argv.length() { + if max_more > 0 && consumed >= max_more { break } - match assign_value(matches, arg, value, ValueSource::Argv) { - Ok(_) => () - Err(e) => return Err(e) + let value = argv[start + consumed] + if should_stop_option_value(value, arg, long_index, short_index) { + break } + assign_value(matches, arg, value, ValueSource::Argv) consumed = consumed + 1 } - Ok(consumed) + consumed } ///| -fn starts_known_option( - arg : String, - long_index : Map[String, Arg], - short_index : Map[Char, Arg], +fn should_stop_option_value( + value : String, + arg : Arg, + _long_index : Map[String, Arg], + _short_index : Map[Char, Arg], ) -> Bool { - if !arg.has_prefix("-") || arg == "-" { + if !value.has_prefix("-") || value == "-" { return false } - if arg.has_prefix("--") { - let (name, _) = split_long(arg) - if long_index.get(name) is Some(_) { - return true - } - if name.has_prefix("no-") { - let target = match name.strip_prefix("no-") { - Some(view) => view.to_string() - None => "" - } - match long_index.get(target) { - Some(spec) => !arg_takes_value(spec) && spec.negatable - None => false - } - } else { - false - } - } else { - match arg.get_char(1) { - Some(ch) => short_index.get(ch) is Some(_) - None => 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 } ///| @@ -988,7 +1077,7 @@ fn apply_env( matches : Matches, args : Array[Arg], env : Map[String, String], -) -> Result[Unit, ArgParseError] { +) -> Unit raise ArgParseError { for arg in args { let name = arg.name if matches_has_value_or_flag(matches, name) { @@ -1003,52 +1092,36 @@ fn apply_env( None => continue } if arg_takes_value(arg) { - match assign_value(matches, arg, value, ValueSource::Env) { - Ok(_) => () - Err(e) => return Err(e) - } + assign_value(matches, arg, value, ValueSource::Env) continue } match arg_action(arg) { - ArgAction::Count => - match parse_count(value) { - Ok(count) => { - matches.counts[name] = count - matches.flags[name] = count > 0 - matches.flag_sources[name] = ValueSource::Env - } - Err(e) => return Err(e) - } - ArgAction::SetFalse => - match parse_bool(value) { - Ok(flag) => { - matches.flags[name] = !flag - matches.flag_sources[name] = ValueSource::Env - } - Err(e) => return Err(e) - } - ArgAction::SetTrue => - match parse_bool(value) { - Ok(flag) => { - matches.flags[name] = flag - matches.flag_sources[name] = ValueSource::Env - } - Err(e) => return Err(e) - } - ArgAction::Set => - match parse_bool(value) { - Ok(flag) => { - matches.flags[name] = flag - matches.flag_sources[name] = ValueSource::Env - } - Err(e) => return Err(e) - } + 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 => () } } - Ok(()) } ///| @@ -1123,40 +1196,31 @@ fn apply_flag(matches : Matches, arg : Arg, source : ValueSource) -> Unit { } ///| -fn parse_bool(value : String) -> Result[Bool, ArgParseError] { +fn parse_bool(value : String) -> Bool raise ArgParseError { if value == "1" || value == "true" || value == "yes" || value == "on" { - Ok(true) + true } else if value == "0" || value == "false" || value == "no" || value == "off" { - Ok(false) + false } else { - Err( - ArgParseError::InvalidValue( - "invalid value '\{value}' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off", - ), + 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) -> Result[Int, ArgParseError] { - let res : Result[Int, Error] = try? @strconv.parse_int(value) - match res { - Ok(v) => - if v >= 0 { - Ok(v) - } else { - Err( - ArgParseError::InvalidValue( - "invalid value '\{value}' for count; expected a non-negative integer", - ), - ) - } - Err(_) => - Err( - ArgParseError::InvalidValue( - "invalid value '\{value}' for count; expected a non-negative integer", - ), +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 } } @@ -1334,12 +1398,14 @@ fn collect_globals(args : Array[Arg]) -> Array[Arg] { } ///| -fn concat_globals(parent : Array[Arg], more : Array[Arg]) -> Array[Arg] { - let out = clone_array(parent) - for arg in more { - out.push(arg) +fn collect_non_global_names(args : Array[Arg]) -> Map[String, Bool] { + let names : Map[String, Bool] = {} + for arg in args { + if !arg.global { + names[arg.name] = true + } } - out + names } ///| @@ -1396,9 +1462,13 @@ fn merge_globals_from_child( parent : Matches, child : Matches, globals : Array[Arg], + child_local_non_globals : Map[String, Bool], ) -> Unit { for arg in globals { let name = arg.name + if child_local_non_globals.get(name) is Some(_) { + continue + } if arg_takes_value(arg) { let parent_vals = parent.values.get(name) let child_vals = child.values.get(name) @@ -1446,15 +1516,15 @@ fn merge_globals_from_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] = clone_array(cv) + parent.values[name] = cv.copy() } if child_srcs is Some(cs) && cs.length() > 0 { - parent.value_sources[name] = clone_array(cs) + parent.value_sources[name] = cs.copy() } } else if parent_vals is Some(pv) && pv.length() > 0 { - parent.values[name] = clone_array(pv) + parent.values[name] = pv.copy() if parent_srcs is Some(ps) && ps.length() > 0 { - parent.value_sources[name] = clone_array(ps) + parent.value_sources[name] = ps.copy() } } } @@ -1463,15 +1533,15 @@ fn merge_globals_from_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] = clone_array(cv) + parent.values[name] = cv.copy() } if child_srcs is Some(cs) && cs.length() > 0 { - parent.value_sources[name] = clone_array(cs) + parent.value_sources[name] = cs.copy() } } else if parent_vals is Some(pv) && pv.length() > 0 { - parent.values[name] = clone_array(pv) + parent.values[name] = pv.copy() if parent_srcs is Some(ps) && ps.length() > 0 { - parent.value_sources[name] = clone_array(ps) + parent.value_sources[name] = ps.copy() } } } @@ -1532,15 +1602,19 @@ fn propagate_globals_to_child( parent : Matches, child : Matches, globals : Array[Arg], + child_local_non_globals : Map[String, Bool], ) -> Unit { for arg in globals { let name = arg.name + if child_local_non_globals.get(name) is Some(_) { + continue + } if arg_takes_value(arg) { match parent.values.get(name) { Some(values) => { - child.values[name] = clone_array(values) + child.values[name] = values.copy() match parent.value_sources.get(name) { - Some(srcs) => child.value_sources[name] = clone_array(srcs) + Some(srcs) => child.value_sources[name] = srcs.copy() None => () } } @@ -1567,16 +1641,12 @@ fn propagate_globals_to_child( } } -///| - -///| - ///| fn positional_args(args : Array[Arg]) -> Array[Arg] { let with_index = [] let without_index = [] for arg in args { - if arg.long is None && arg.short is None { + if is_positional_arg(arg) { if arg.index is Some(idx) { with_index.push((idx, arg)) } else { @@ -1610,11 +1680,44 @@ fn last_positional_index(positionals : Array[Arg]) -> Int? { ///| fn next_positional(positionals : Array[Arg], collected : Array[String]) -> Arg? { - if collected.length() < positionals.length() { - Some(positionals[collected.length()]) - } else { - None + 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 } ///| @@ -1721,10 +1824,7 @@ fn resolve_help_target( } match find_subcommand(subs, name) { Some(sub) => { - current_globals = concat_globals( - current_globals, - collect_globals(command_args(current)), - ) + current_globals = current_globals + collect_globals(current.args) current = sub subs = sub.subcommands } @@ -1756,12 +1856,3 @@ fn split_long(arg : String) -> (String, String?) { (name, Some(value)) } } - -///| -fn[T] clone_array(arr : Array[T]) -> Array[T] { - let out = Array::new(capacity=arr.length()) - for value in arr { - out.push(value) - } - out -} diff --git a/argparse/value_range.mbt b/argparse/value_range.mbt index 651eb27f3..91e8f9d35 100644 --- a/argparse/value_range.mbt +++ b/argparse/value_range.mbt @@ -15,10 +15,8 @@ ///| /// Number-of-values constraint for an argument. pub struct ValueRange { - priv lower : Int? + priv lower : Int priv upper : Int? - priv lower_inclusive : Bool - priv upper_inclusive : Bool fn new( lower? : Int, @@ -45,5 +43,13 @@ pub fn ValueRange::new( lower_inclusive? : Bool = true, upper_inclusive? : Bool = true, ) -> ValueRange { - ValueRange::{ lower, upper, lower_inclusive, upper_inclusive } + let lower = match lower { + None => 0 + Some(lower) => if lower_inclusive { lower } else { lower + 1 } + } + let upper = match upper { + None => None + Some(upper) => Some(if upper_inclusive { upper } else { upper - 1 }) + } + ValueRange::{ lower, upper } } From 77bbd0e2857c8ddfb11c6784bbd874a5a1cfcffa Mon Sep 17 00:00:00 2001 From: flycloudc Date: Wed, 11 Feb 2026 17:17:58 +0800 Subject: [PATCH 3/5] refactor: build the command group in advance --- argparse/README.mbt.md | 15 + argparse/arg_action.mbt | 26 +- argparse/arg_group.mbt | 7 + argparse/arg_spec.mbt | 78 +- argparse/argparse_blackbox_test.mbt | 608 +++++++--- argparse/argparse_test.mbt | 112 +- argparse/command.mbt | 122 +- argparse/help_render.mbt | 23 +- argparse/moon.pkg | 1 + argparse/parser.mbt | 1636 ++------------------------- argparse/parser_globals_merge.mbt | 266 +++++ argparse/parser_lookup.mbt | 116 ++ argparse/parser_positionals.mbt | 158 +++ argparse/parser_suggest.mbt | 115 ++ argparse/parser_validate.mbt | 518 +++++++++ argparse/parser_values.mbt | 317 ++++++ argparse/pkg.generated.mbti | 17 +- argparse/value_range.mbt | 34 +- 18 files changed, 2216 insertions(+), 1953 deletions(-) create mode 100644 argparse/parser_globals_merge.mbt create mode 100644 argparse/parser_lookup.mbt create mode 100644 argparse/parser_positionals.mbt create mode 100644 argparse/parser_suggest.mbt create mode 100644 argparse/parser_validate.mbt create mode 100644 argparse/parser_values.mbt diff --git a/argparse/README.mbt.md b/argparse/README.mbt.md index dd493e82b..bc895e2b8 100644 --- a/argparse/README.mbt.md +++ b/argparse/README.mbt.md @@ -2,6 +2,21 @@ 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`. diff --git a/argparse/arg_action.mbt b/argparse/arg_action.mbt index d051bbbd5..3f09e86e2 100644 --- a/argparse/arg_action.mbt +++ b/argparse/arg_action.mbt @@ -49,16 +49,26 @@ fn arg_action(arg : Arg) -> ArgAction { ///| fn arg_min_max_for_validate(arg : Arg) -> (Int, Int?) raise ArgBuildError { - match arg.num_args { - Some(range) => { - match range.upper { - Some(max_value) if max_value < range.lower => - raise ArgBuildError::Unsupported("max values must be >= min values") - _ => () + 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) } - None => (0, None) + (range.lower, range.upper) + } else { + (0, None) } } diff --git a/argparse/arg_group.mbt b/argparse/arg_group.mbt index f7736335c..5147ae5fd 100644 --- a/argparse/arg_group.mbt +++ b/argparse/arg_group.mbt @@ -22,6 +22,7 @@ pub struct ArgGroup { priv requires : Array[String] priv conflicts_with : Array[String] + /// Create an argument group. fn new( name : String, required? : Bool, @@ -33,6 +34,12 @@ pub struct 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, diff --git a/argparse/arg_spec.mbt b/argparse/arg_spec.mbt index 29909fb0d..c00949976 100644 --- a/argparse/arg_spec.mbt +++ b/argparse/arg_spec.mbt @@ -49,7 +49,6 @@ priv struct Arg { last : Bool requires : Array[String] conflicts_with : Array[String] - group : String? required : Bool global : Bool negatable : Bool @@ -60,6 +59,7 @@ priv struct Arg { /// Trait for declarative arg constructors. trait ArgLike { to_arg(Self) -> Arg + validate(Self, ValidationCtx) -> Unit raise ArgBuildError } ///| @@ -67,6 +67,7 @@ trait ArgLike { pub struct FlagArg { priv arg : Arg + /// Create a flag argument. fn new( name : String, short? : Char, @@ -76,7 +77,6 @@ pub struct FlagArg { env? : String, requires? : Array[String], conflicts_with? : Array[String], - group? : String, required? : Bool, global? : Bool, negatable? : Bool, @@ -90,6 +90,18 @@ pub impl ArgLike for FlagArg with to_arg(self : FlagArg) { } ///| +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, @@ -99,7 +111,6 @@ pub fn FlagArg::new( env? : String, requires? : Array[String] = [], conflicts_with? : Array[String] = [], - group? : String, required? : Bool = false, global? : Bool = false, negatable? : Bool = false, @@ -124,7 +135,6 @@ pub fn FlagArg::new( last: false, requires: requires.copy(), conflicts_with: conflicts_with.copy(), - group, required, global, negatable, @@ -138,6 +148,7 @@ pub fn FlagArg::new( pub struct OptionArg { priv arg : Arg + /// Create an option argument. fn new( name : String, short? : Char, @@ -146,12 +157,10 @@ pub struct OptionArg { action? : OptionAction, env? : String, default_values? : Array[String], - num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], - group? : String, required? : Bool, global? : Bool, hidden? : Bool, @@ -164,6 +173,18 @@ pub impl ArgLike for OptionArg with to_arg(self : OptionArg) { } ///| +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, @@ -172,12 +193,10 @@ pub fn OptionArg::new( action? : OptionAction = OptionAction::Set, env? : String, default_values? : Array[String], - num_args? : ValueRange, allow_hyphen_values? : Bool = false, last? : Bool = false, requires? : Array[String] = [], conflicts_with? : Array[String] = [], - group? : String, required? : Bool = false, global? : Bool = false, hidden? : Bool = false, @@ -195,13 +214,12 @@ pub fn OptionArg::new( option_action: action, env, default_values: default_values.map(Array::copy), - num_args, - multiple: allows_multiple_values(num_args, action), + num_args: None, + multiple: allows_multiple_values(action), allow_hyphen_values, last, requires: requires.copy(), conflicts_with: conflicts_with.copy(), - group, required, global, negatable: false, @@ -215,6 +233,7 @@ pub fn OptionArg::new( pub struct PositionalArg { priv arg : Arg + /// Create a positional argument. fn new( name : String, index? : Int, @@ -226,7 +245,6 @@ pub struct PositionalArg { last? : Bool, requires? : Array[String], conflicts_with? : Array[String], - group? : String, required? : Bool, global? : Bool, hidden? : Bool, @@ -239,6 +257,23 @@ pub impl ArgLike for PositionalArg with to_arg(self : PositionalArg) { } ///| +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, @@ -250,7 +285,6 @@ pub fn PositionalArg::new( last? : Bool = false, requires? : Array[String] = [], conflicts_with? : Array[String] = [], - group? : String, required? : Bool = false, global? : Bool = false, hidden? : Bool = false, @@ -274,7 +308,6 @@ pub fn PositionalArg::new( last, requires: requires.copy(), conflicts_with: conflicts_with.copy(), - group, required, global, negatable: false, @@ -299,24 +332,17 @@ fn is_count_flag_spec(arg : Arg) -> Bool { } ///| -fn allows_multiple_values( - num_args : ValueRange?, - action : OptionAction, -) -> Bool { - action == OptionAction::Append || range_allows_multiple(num_args) +fn allows_multiple_values(action : OptionAction) -> Bool { + action == OptionAction::Append } ///| fn range_allows_multiple(range : ValueRange?) -> Bool { match range { Some(r) => - if r.lower > 1 { - true - } else { - match r.upper { - Some(value) => value > 1 - None => true - } + 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 index b1a1325e2..262bbd422 100644 --- a/argparse/argparse_blackbox_test.mbt +++ b/argparse/argparse_blackbox_test.mbt @@ -33,7 +33,7 @@ test "render help snapshot with groups and hidden entries" { "render", groups=[ @argparse.ArgGroup("mode", required=true, multiple=false, args=[ - "fast", "path", + "fast", "slow", "path", ]), ], subcommands=[ @@ -41,8 +41,8 @@ test "render help snapshot with groups and hidden entries" { @argparse.Command("hidden", about="hidden", hidden=true), ], args=[ - @argparse.FlagArg("fast", short='f', long="fast", group="mode"), - @argparse.FlagArg("slow", long="slow", group="mode", hidden=true), + @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", @@ -51,7 +51,6 @@ test "render help snapshot with groups and hidden entries" { env="PATH_ENV", default_values=["a", "b"], required=true, - group="mode", ), @argparse.PositionalArg("target", index=0, required=true), @argparse.PositionalArg( @@ -65,7 +64,7 @@ test "render help snapshot with groups and hidden entries" { inspect( cmd.render_help(), content=( - #|Usage: render [options] [rest...] + #|Usage: render [options] [rest...] [command] #| #|Commands: #| run run @@ -103,7 +102,6 @@ test "render help conversion coverage snapshot" { required=true, global=true, hidden=true, - group="grp", ), @argparse.OptionArg( "opt", @@ -117,7 +115,6 @@ test "render help conversion coverage snapshot" { global=true, hidden=true, conflicts_with=["pos"], - group="grp", ), @argparse.PositionalArg( "pos", @@ -129,7 +126,6 @@ test "render help conversion coverage snapshot" { last=true, requires=["opt"], conflicts_with=["f"], - group="grp", required=true, global=true, hidden=true, @@ -197,6 +193,30 @@ test "global option merges parent and child values" { ) } +///| +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") @@ -340,6 +360,27 @@ test "global flag keeps parent argv over child env fallback" { ) } +///| +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") @@ -419,7 +460,7 @@ test "help subcommand styles and errors" { inspect( text, content=( - #|Usage: demo + #|Usage: demo [command] #| #|Commands: #| echo echo @@ -704,10 +745,9 @@ test "bounded positional does not greedily consume later required values" { let cmd = @argparse.Command("demo", args=[ @argparse.PositionalArg( "first", - index=0, num_args=@argparse.ValueRange(lower=1, upper=2), ), - @argparse.PositionalArg("second", index=1, required=true), + @argparse.PositionalArg("second", required=true), ]) let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } @@ -719,6 +759,43 @@ test "bounded positional does not greedily consume later required values" { 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=[ @@ -816,8 +893,8 @@ test "defaults and value range helpers through public API" { @argparse.OptionArg( "mode", long="mode", + action=@argparse.OptionAction::Append, default_values=["a", "b"], - num_args=@argparse.ValueRange(lower=1), ), @argparse.OptionArg("one", long="one", default_values=["x"]), ]) @@ -839,7 +916,6 @@ test "defaults and value range helpers through public API" { "tag", long="tag", action=@argparse.OptionAction::Append, - num_args=@argparse.ValueRange(lower=1, upper=2), ), ]) let upper_parsed = upper_only.parse( @@ -851,11 +927,7 @@ test "defaults and value range helpers through public API" { assert_true(upper_parsed.values is { "tag": ["a", "b", "c"], .. }) let lower_only = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "tag", - long="tag", - num_args=@argparse.ValueRange(lower=1), - ), + @argparse.OptionArg("tag", long="tag"), ]) let lower_absent = lower_only.parse(argv=[], env=empty_env()) catch { _ => panic() @@ -869,30 +941,32 @@ test "defaults and value range helpers through public API" { _ => panic() } - let empty_range = @argparse.ValueRange::empty() let single_range = @argparse.ValueRange::single() inspect( - (empty_range, single_range), + single_range, content=( - #|({lower: 0, upper: Some(0)}, {lower: 1, upper: Some(1)}) + #|{lower: 1, upper: Some(1)} ), ) } ///| -test "num_args options consume argv values in one occurrence" { +test "options consume exactly one value per occurrence" { let cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "tag", - long="tag", - num_args=@argparse.ValueRange(lower=2, upper=2), - ), + @argparse.OptionArg("tag", long="tag"), ]) - let parsed = cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { + let parsed = cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.values is { "tag": ["a", "b"], .. }) + 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() + } } ///| @@ -939,15 +1013,15 @@ test "flag and option args require short or long names" { } ///| -test "num_args range option consumes optional extra argv value" { +test "append options collect values across repeated occurrences" { let cmd = @argparse.Command("demo", args=[ @argparse.OptionArg( "arg", long="arg", - num_args=@argparse.ValueRange(lower=1, upper=2), + action=@argparse.OptionAction::Append, ), ]) - let parsed = cmd.parse(argv=["--arg", "x", "y"], env=empty_env()) catch { + let parsed = cmd.parse(argv=["--arg", "x", "--arg", "y"], env=empty_env()) catch { _ => panic() } assert_true(parsed.values is { "arg": ["x", "y"], .. }) @@ -955,14 +1029,9 @@ test "num_args range option consumes optional extra argv value" { } ///| -test "num_args range option stops at the next option token" { +test "option parsing stops at the next option token" { let cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "arg", - short='a', - long="arg", - num_args=@argparse.ValueRange(lower=1, upper=2), - ), + @argparse.OptionArg("arg", short='a', long="arg"), @argparse.FlagArg("verbose", long="verbose"), ]) @@ -972,54 +1041,40 @@ test "num_args range option stops at the next option token" { assert_true(stopped.values is { "arg": ["x"], .. }) assert_true(stopped.flags is { "verbose": true, .. }) - let inline = cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { + try cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { _ => panic() } - assert_true(inline.values is { "arg": ["x", "y"], .. }) - assert_true(inline.flags is { "verbose": true, .. }) - let short_inline = cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { + try cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { + @argparse.ArgParseError::TooManyPositionals => () + _ => panic() + } noraise { _ => panic() } - assert_true(short_inline.values is { "arg": ["x", "y"], .. }) - assert_true(short_inline.flags is { "verbose": true, .. }) } ///| -test "option num_args cannot be flag-like" { - try - @argparse.Command("demo", args=[ - @argparse.OptionArg( - "opt", - long="opt", - num_args=@argparse.ValueRange(lower=0, upper=1), - ), - @argparse.FlagArg("verbose", long="verbose"), - ]).parse(argv=["--opt", "--verbose"], env=empty_env()) - catch { - @argparse.ArgBuildError::Unsupported(msg) => - inspect(msg, content="option args require at least one value") +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() } - try - @argparse.Command("demo", args=[ - @argparse.OptionArg( - "opt", - long="opt", - required=true, - num_args=@argparse.ValueRange(lower=0, upper=0), - ), - ]).parse(argv=["--opt"], env=empty_env()) - catch { - @argparse.ArgBuildError::Unsupported(msg) => - inspect(msg, content="option args require at least one value") - _ => panic() - } noraise { + 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"], .. }) } ///| @@ -1160,19 +1215,12 @@ test "validation branches exposed through parse" { } try - @argparse.Command("demo", args=[ - @argparse.OptionArg( - "x", - long="x", - num_args=@argparse.ValueRange(lower=2, upper=2), - ), - ]).parse(argv=["--x", "a", "--x", "b"], env=empty_env()) + @argparse.Command("demo", args=[@argparse.OptionArg("x", long="x")]).parse( + argv=["--x", "a", "b"], + env=empty_env(), + ) catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - assert_true(name == "x") - assert_true(got == 1) - assert_true(min == 2) - } + @argparse.ArgParseError::TooManyPositionals => () _ => panic() } noraise { _ => panic() @@ -1187,7 +1235,7 @@ test "validation branches exposed through parse" { inspect( msg, content=( - #|default_values require action=Append or num_args allowing >1 + #|default_values with multiple entries require action=Append ), ) _ => panic() @@ -1197,9 +1245,8 @@ test "validation branches exposed through parse" { try @argparse.Command("demo", args=[ - @argparse.OptionArg( + @argparse.PositionalArg( "x", - long="x", num_args=@argparse.ValueRange(lower=3, upper=2), ), ]).parse(argv=[], env=empty_env()) @@ -1216,6 +1263,86 @@ test "validation branches exposed through parse" { _ => 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"), @@ -1622,9 +1749,63 @@ test "group validation catches unknown conflicts_with target" { } ///| -test "group assignment auto-creates missing group definition" { +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", group="missing"), + @argparse.FlagArg("x", long="x"), ]) let parsed = cmd.parse(argv=["--x"], env=empty_env()) catch { _ => panic() } assert_true(parsed.flags is { "x": true, .. }) @@ -1666,10 +1847,13 @@ test "arg validation catches unknown conflicts_with target" { test "empty groups without presence do not fail" { let grouped_ok = @argparse.Command( "demo", - groups=[@argparse.ArgGroup("left"), @argparse.ArgGroup("right")], + groups=[ + @argparse.ArgGroup("left", args=["l"]), + @argparse.ArgGroup("right", args=["r"]), + ], args=[ - @argparse.FlagArg("l", long="left", group="left"), - @argparse.FlagArg("r", long="right", group="right"), + @argparse.FlagArg("l", long="left"), + @argparse.FlagArg("r", long="right"), ], ) let parsed = grouped_ok.parse(argv=["--left"], env=empty_env()) catch { @@ -1734,7 +1918,7 @@ test "help rendering edge paths stay stable" { assert_true(empty_options_help.has_prefix("Usage: demo")) let implicit_group = @argparse.Command("demo", args=[ - @argparse.PositionalArg("item", index=0, group="dyn"), + @argparse.PositionalArg("item", index=0), ]) let implicit_group_help = implicit_group.render_help() assert_true(implicit_group_help.has_prefix("Usage: demo [item]")) @@ -1743,7 +1927,7 @@ test "help rendering edge paths stay stable" { @argparse.Command("run"), ]) let sub_help = sub_visible.render_help() - assert_true(sub_help.has_prefix("Usage: demo ")) + assert_true(sub_help.has_prefix("Usage: demo [command]")) } ///| @@ -1791,18 +1975,21 @@ test "parse error formatting covers public variants" { } ///| -test "range constructors with open lower bound still validate shape rules" { +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", - num_args=@argparse.ValueRange::new(upper=2), - ), - ]).parse(argv=["--tag", "x"], env=empty_env()) + @argparse.Command("demo", args=[@argparse.OptionArg("tag", long="tag")]).parse( + argv=["--tag"], + env=empty_env(), + ) catch { - @argparse.ArgBuildError::Unsupported(msg) => - inspect(msg, content="option args require at least one value") + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--tag") _ => panic() } noraise { _ => panic() @@ -1810,21 +1997,19 @@ test "range constructors with open lower bound still validate shape rules" { } ///| -test "short option with bounded values reports per occurrence too few values" { +test "short options require one value before next option token" { let cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "x", - short='x', - num_args=@argparse.ValueRange(lower=2, upper=2), - ), + @argparse.OptionArg("x", short='x'), @argparse.FlagArg("verbose", short='v'), ]) - try cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - assert_true(name == "x") - assert_true(got == 1) - assert_true(min == 2) - } + 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() @@ -1861,6 +2046,45 @@ test "version action dispatches on custom long and short flags" { } } +///| +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=[ @@ -1875,45 +2099,49 @@ test "required and env-fed ranged values validate after parsing" { } let env_min_cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "pair", - long="pair", - env="PAIR", - num_args=@argparse.ValueRange(lower=2, upper=3), - ), + @argparse.OptionArg("pair", long="pair", env="PAIR"), ]) - try env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - assert_true(name == "pair") - assert_true(got == 1) - assert_true(min == 2) - } + let env_value = env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { _ => panic() - } noraise { + } + 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 "positionals hit balancing branches and explicit index sorting" { +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), ), - @argparse.PositionalArg("late", index=2, required=true), - @argparse.PositionalArg( - "mid", - index=1, - num_args=@argparse.ValueRange(lower=2, upper=2), - ), ]) - try cmd.parse(argv=["a"], env=empty_env()) catch { + try cmd.parse(argv=[], env=empty_env()) catch { @argparse.ArgParseError::TooFewValues(name, got, min) => { assert_true(name == "first") - assert_true(got == 1) + assert_true(got == 0) assert_true(min == 2) } _ => panic() @@ -1927,10 +2155,9 @@ test "positional max clamp leaves trailing value for next positional" { let cmd = @argparse.Command("demo", args=[ @argparse.PositionalArg( "items", - index=0, num_args=@argparse.ValueRange(lower=0, upper=2), ), - @argparse.PositionalArg("tail", index=1), + @argparse.PositionalArg("tail"), ]) let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { @@ -1940,52 +2167,42 @@ test "positional max clamp leaves trailing value for next positional" { } ///| -test "open upper range options consume option-like values with allow_hyphen_values" { +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, - num_args=@argparse.ValueRange(lower=1), - ), + @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", "x", "--verbose"], env=empty_env()) catch { + let known_long = cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { _ => panic() } - assert_true(known_long.values is { "arg": ["x", "--verbose"], .. }) + assert_true(known_long.values is { "arg": ["--verbose"], .. }) assert_true(known_long.flags is { "verbose"? : None, .. }) - let negated = cmd.parse(argv=["--arg", "x", "--no-cache"], env=empty_env()) catch { + let negated = cmd.parse(argv=["--arg", "--no-cache"], env=empty_env()) catch { _ => panic() } - assert_true(negated.values is { "arg": ["x", "--no-cache"], .. }) + assert_true(negated.values is { "arg": ["--no-cache"], .. }) assert_true(negated.flags is { "cache"? : None, .. }) let unknown_long_value = cmd.parse( - argv=["--arg", "x", "--mystery"], + argv=["--arg", "--mystery"], env=empty_env(), ) catch { _ => panic() } - assert_true(unknown_long_value.values is { "arg": ["x", "--mystery"], .. }) + assert_true(unknown_long_value.values is { "arg": ["--mystery"], .. }) - let known_short = cmd.parse(argv=["--arg", "x", "-q"], env=empty_env()) catch { + let known_short = cmd.parse(argv=["--arg", "-q"], env=empty_env()) catch { _ => panic() } - assert_true(known_short.values is { "arg": ["x", "-q"], .. }) + 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, - num_args=@argparse.ValueRange(lower=1), - ), + @argparse.OptionArg("arg", long="arg", allow_hyphen_values=true), @argparse.PositionalArg( "rest", index=0, @@ -1999,19 +2216,13 @@ test "open upper range options consume option-like values with allow_hyphen_valu ) catch { _ => panic() } - assert_true( - sentinel_stop.values is { "arg": ["x", "--", "tail"], "rest"? : None, .. }, - ) + assert_true(sentinel_stop.values is { "arg": ["x"], "rest": ["tail"], .. }) } ///| -test "fixed upper range avoids consuming additional option values" { +test "single-value options avoid consuming additional option values" { let cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "one", - long="one", - num_args=@argparse.ValueRange(lower=1, upper=1), - ), + @argparse.OptionArg("one", long="one"), @argparse.FlagArg("verbose", long="verbose"), ]) @@ -2023,28 +2234,20 @@ test "fixed upper range avoids consuming additional option values" { } ///| -test "bounded long options report too few values when next token is another option" { +test "missing option values are reported when next token is another option" { let cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "arg", - long="arg", - num_args=@argparse.ValueRange(lower=2, upper=2), - ), + @argparse.OptionArg("arg", long="arg"), @argparse.FlagArg("verbose", long="verbose"), ]) - let ok = cmd.parse(argv=["--arg", "x", "y", "--verbose"], env=empty_env()) catch { + let ok = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { _ => panic() } - assert_true(ok.values is { "arg": ["x", "y"], .. }) + assert_true(ok.values is { "arg": ["x"], .. }) assert_true(ok.flags is { "verbose": true, .. }) - try cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - assert_true(name == "arg") - assert_true(got == 1) - assert_true(min == 2) - } + try cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { + @argparse.ArgParseError::MissingValue(name) => assert_true(name == "--arg") _ => panic() } noraise { _ => panic() @@ -2137,7 +2340,7 @@ test "global value from child default is merged back to parent" { } ///| -test "child local arg with global name does not update parent global" { +test "child global arg with inherited global name updates parent global" { let cmd = @argparse.Command( "demo", args=[ @@ -2149,15 +2352,17 @@ test "child local arg with global name does not update parent global" { ), ], subcommands=[ - @argparse.Command("run", args=[@argparse.OptionArg("mode", long="mode")]), + @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": ["safe"], .. }) - assert_true(parsed.sources is { "mode": @argparse.ValueSource::Default, .. }) + 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"], .. } && @@ -2165,6 +2370,33 @@ test "child local arg with global name does not update parent global" { ) } +///| +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( diff --git a/argparse/argparse_test.mbt b/argparse/argparse_test.mbt index 4f72c1549..63e82a27b 100644 --- a/argparse/argparse_test.mbt +++ b/argparse/argparse_test.mbt @@ -98,121 +98,30 @@ test "relationships and num args" { _ => panic() } - let num_args_cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "tag", - long="tag", - num_args=@argparse.ValueRange(lower=2, upper=2), - ), - ]) - - try num_args_cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - inspect(name, content="tag") - inspect(got, content="1") - inspect(min, content="2") - } - _ => panic() - } noraise { - _ => panic() - } - - try - num_args_cmd.parse( - argv=["--tag", "a", "--tag", "b", "--tag", "c"], - env=empty_env(), - ) - catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - inspect(name, content="tag") - inspect(got, content="1") - inspect(min, content="2") - } - _ => panic() - } noraise { - _ => panic() - } - - let append_num_args_cmd = @argparse.Command("demo", args=[ + let appended = @argparse.Command("demo", args=[ @argparse.OptionArg( "tag", long="tag", action=@argparse.OptionAction::Append, - num_args=@argparse.ValueRange(lower=1, upper=2), ), - ]) - let appended = append_num_args_cmd.parse( - argv=["--tag", "a", "--tag", "b", "--tag", "c"], - env=empty_env(), - ) catch { + ]).parse(argv=["--tag", "a", "--tag", "b", "--tag", "c"], env=empty_env()) catch { _ => panic() } assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) - - let append_fixed_cmd = @argparse.Command("demo", args=[ - @argparse.OptionArg( - "tag", - long="tag", - action=@argparse.OptionAction::Append, - num_args=@argparse.ValueRange(lower=2, upper=2), - ), - ]) - try - append_fixed_cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) - catch { - @argparse.ArgParseError::TooFewValues(name, got, min) => { - inspect(name, content="tag") - inspect(got, content="1") - inspect(min, content="2") - } - _ => panic() - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", args=[ - @argparse.OptionArg( - "opt", - long="opt", - num_args=@argparse.ValueRange(lower=0, upper=1), - ), - @argparse.FlagArg("verbose", long="verbose"), - ]).parse(argv=["--opt", "--verbose"], env=empty_env()) - catch { - @argparse.ArgBuildError::Unsupported(msg) => - inspect(msg, content="option args require at least one value") - _ => panic() - } noraise { - _ => panic() - } - - try - @argparse.Command("demo", args=[ - @argparse.OptionArg( - "opt", - long="opt", - num_args=@argparse.ValueRange(lower=0, upper=0), - required=true, - ), - ]).parse(argv=["--opt"], env=empty_env()) - catch { - @argparse.ArgBuildError::Unsupported(msg) => - inspect(msg, content="option args require at least one value") - _ => panic() - } noraise { - _ => panic() - } } ///| test "arg groups required and multiple" { let cmd = @argparse.Command( "demo", - groups=[@argparse.ArgGroup("mode", required=true, multiple=false)], + groups=[ + @argparse.ArgGroup("mode", required=true, multiple=false, args=[ + "fast", "slow", + ]), + ], args=[ - @argparse.FlagArg("fast", long="fast", group="mode"), - @argparse.FlagArg("slow", long="slow", group="mode"), + @argparse.FlagArg("fast", long="fast"), + @argparse.FlagArg("slow", long="slow"), ], ) @@ -318,7 +227,7 @@ test "full help snapshot" { inspect( cmd.render_help(), content=( - #|Usage: demo [options] [name] + #|Usage: demo [options] [name] [command] #| #|Demo command #| @@ -505,6 +414,7 @@ test "command policies" { 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") diff --git a/argparse/command.mbt b/argparse/command.mbt index 523c4d2a6..37a25918e 100644 --- a/argparse/command.mbt +++ b/argparse/command.mbt @@ -27,7 +27,9 @@ pub struct Command { 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], @@ -45,6 +47,12 @@ pub struct 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] = [], @@ -59,10 +67,12 @@ pub fn Command::new( hidden? : Bool = false, groups? : Array[ArgGroup] = [], ) -> Command { - Command::{ + let (parsed_args, arg_error) = collect_args(args) + let groups = groups.copy() + let cmd = Command::{ name, - args: args.map(x => x.to_arg()), - groups: groups.copy(), + args: parsed_args, + groups, subcommands: subcommands.copy(), about, version, @@ -72,7 +82,14 @@ pub fn Command::new( 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 } ///| @@ -83,12 +100,20 @@ pub fn Command::render_help(self : Command) -> String { ///| /// 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, []) + let raw = parse_command(self, argv, env, [], {}, {}) build_matches(self, raw, []) } @@ -143,26 +168,25 @@ fn build_matches( let subcommand = match raw.parsed_subcommand { Some((name, sub_raw)) => - match find_decl_subcommand(cmd.subcommands, name) { - Some(sub_spec) => - Some((name, build_matches(sub_spec, sub_raw, child_globals))) - None => - Some( - ( - name, - Matches::{ - flags: {}, - values: {}, - flag_counts: {}, - sources: {}, - subcommand: None, - counts: {}, - flag_sources: {}, - value_sources: {}, - parsed_subcommand: None, - }, - ), - ) + 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 } @@ -191,47 +215,19 @@ fn find_decl_subcommand(subs : Array[Command], name : String) -> Command? { } ///| -fn command_groups(cmd : Command) -> Array[ArgGroup] { - let groups = cmd.groups.copy() - for arg in cmd.args { - match arg.group { - Some(group_name) => - add_arg_to_group_membership(groups, group_name, arg_name(arg)) - None => () - } - } - groups -} - -///| -fn add_arg_to_group_membership( - groups : Array[ArgGroup], - group_name : String, - arg_name : String, -) -> Unit { - let mut idx : Int? = None - for i = 0; i < groups.length(); i = i + 1 { - if groups[i].name == group_name { - idx = Some(i) - break +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) } } } - match idx { - Some(i) => { - if groups[i].args.contains(arg_name) { - return - } - let args = [..groups[i].args, arg_name] - groups[i] = ArgGroup::{ ..groups[i], args, } + if first_error is None { + ctx.finalize() catch { + err => first_error = Some(err) } - None => - groups.push(ArgGroup::{ - name: group_name, - required: false, - multiple: true, - args: [arg_name], - requires: [], - conflicts_with: [], - }) } + (args, first_error) } diff --git a/argparse/help_render.mbt b/argparse/help_render.mbt index 31a43fb62..592b8e3b2 100644 --- a/argparse/help_render.mbt +++ b/argparse/help_render.mbt @@ -46,16 +46,29 @@ fn usage_tail(cmd : Command) -> String { if has_options(cmd) { tail = "\{tail} [options]" } - if has_subcommands_for_help(cmd) { - tail = "\{tail} " - } 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 { @@ -200,8 +213,8 @@ fn subcommand_entries(cmd : Command) -> Array[String] { ///| fn group_entries(cmd : Command) -> Array[String] { - let display = Array::new(capacity=command_groups(cmd).length()) - for group in command_groups(cmd) { + let display = Array::new(capacity=cmd.groups.length()) + for group in cmd.groups { let members = group_members(cmd, group) if members == "" { continue diff --git a/argparse/moon.pkg b/argparse/moon.pkg index cd148481a..101bb7edf 100644 --- a/argparse/moon.pkg +++ b/argparse/moon.pkg @@ -2,4 +2,5 @@ import { "moonbitlang/core/builtin", "moonbitlang/core/env", "moonbitlang/core/strconv", + "moonbitlang/core/set", } diff --git a/argparse/parser.mbt b/argparse/parser.mbt index acecded95..95ce7c2e9 100644 --- a/argparse/parser.mbt +++ b/argparse/parser.mbt @@ -13,12 +13,12 @@ // limitations under the License. ///| -fn raise_help(text : String) -> Unit raise { +fn raise_help(text : String) -> Unit raise DisplayHelp { raise DisplayHelp::Message(text) } ///| -fn raise_version(text : String) -> Unit raise { +fn raise_version(text : String) -> Unit raise DisplayVersion { raise DisplayVersion::Message(text) } @@ -26,7 +26,7 @@ fn raise_version(text : String) -> Unit raise { fn[T] raise_unknown_long( name : String, long_index : Map[String, Arg], -) -> T raise { +) -> T raise ArgParseError { let hint = suggest_long(name, long_index) raise ArgParseError::UnknownArgument("--\{name}", hint) } @@ -35,11 +35,18 @@ fn[T] raise_unknown_long( fn[T] raise_unknown_short( short : Char, short_index : Map[Char, Arg], -) -> T raise { +) -> 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, @@ -57,7 +64,7 @@ fn render_help_for_context( fn raise_context_help( cmd : Command, inherited_globals : Array[Arg], -) -> Unit raise { +) -> Unit raise DisplayHelp { raise_help(render_help_for_context(cmd, inherited_globals)) } @@ -77,17 +84,34 @@ fn parse_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 = command_groups(cmd) + let groups = cmd.groups let subcommands = cmd.subcommands - validate_command(cmd, args, groups) 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) && @@ -102,9 +126,13 @@ fn parse_command( let positional_values = [] let last_pos_idx = last_positional_index(positionals) 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) } @@ -116,6 +144,7 @@ fn parse_command( } if force_positional { positional_values.push(arg) + positional_arg_found = true i = i + 1 continue } @@ -135,6 +164,7 @@ fn parse_command( arg, positionals, positional_values, long_index, short_index, ) { positional_values.push(arg) + positional_arg_found = true i = i + 1 continue } @@ -186,16 +216,8 @@ fn parse_command( Some(spec) => if arg_takes_value(spec) { check_duplicate_set_occurrence(matches, spec) - let min_values = option_occurrence_min(spec) - let accepts_values = option_accepts_values(spec) - let mut values_start = i + 1 - let mut consumed_first = false if inline is Some(v) { - if !accepts_values { - raise ArgParseError::InvalidArgument(arg) - } assign_value(matches, spec, v, ValueSource::Argv) - consumed_first = true } else { let can_take_next = i + 1 < argv.length() && !should_stop_option_value( @@ -204,29 +226,11 @@ fn parse_command( long_index, short_index, ) - if can_take_next && accepts_values { + if can_take_next { i = i + 1 assign_value(matches, spec, argv[i], ValueSource::Argv) - values_start = i + 1 - consumed_first = true - } else if min_values > 0 { - raise ArgParseError::MissingValue("--\{name}") } else { - mark_option_present(matches, spec, ValueSource::Argv) - } - } - if consumed_first { - let consumed_more = consume_additional_option_values( - matches, spec, argv, values_start, long_index, short_index, - ) - i = i + consumed_more - let occurrence_values = 1 + consumed_more - if occurrence_values < min_values { - raise ArgParseError::TooFewValues( - spec.name, - occurrence_values, - min_values, - ) + raise ArgParseError::MissingValue("--\{name}") } } } else { @@ -235,7 +239,12 @@ fn parse_command( } match arg_action(spec) { ArgAction::Help => raise_context_help(cmd, inherited_globals) - ArgAction::Version => raise_version(command_version(cmd)) + ArgAction::Version => + raise_version( + version_text_for_long_action( + cmd, name, inherited_version_long, + ), + ) _ => apply_flag(matches, spec, ValueSource::Argv) } } @@ -260,21 +269,13 @@ fn parse_command( } if arg_takes_value(spec) { check_duplicate_set_occurrence(matches, spec) - let min_values = option_occurrence_min(spec) - let accepts_values = option_accepts_values(spec) - let mut values_start = i + 1 - let mut consumed_first = false 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 } - if !accepts_values { - raise ArgParseError::InvalidArgument(arg) - } assign_value(matches, spec, inline, ValueSource::Argv) - consumed_first = true } else { let can_take_next = i + 1 < argv.length() && !should_stop_option_value( @@ -283,36 +284,23 @@ fn parse_command( long_index, short_index, ) - if can_take_next && accepts_values { + if can_take_next { i = i + 1 assign_value(matches, spec, argv[i], ValueSource::Argv) - values_start = i + 1 - consumed_first = true - } else if min_values > 0 { - raise ArgParseError::MissingValue("-\{short}") } else { - mark_option_present(matches, spec, ValueSource::Argv) - } - } - if consumed_first { - let consumed_more = consume_additional_option_values( - matches, spec, argv, values_start, long_index, short_index, - ) - i = i + consumed_more - let occurrence_values = 1 + consumed_more - if occurrence_values < min_values { - raise ArgParseError::TooFewValues( - spec.name, - occurrence_values, - min_values, - ) + raise ArgParseError::MissingValue("-\{short}") } } break } else { match arg_action(spec) { ArgAction::Help => raise_context_help(cmd, inherited_globals) - ArgAction::Version => raise_version(command_version(cmd)) + ArgAction::Version => + raise_version( + version_text_for_short_action( + cmd, short, inherited_version_short, + ), + ) _ => apply_flag(matches, spec, ValueSource::Argv) } } @@ -322,6 +310,9 @@ fn parse_command( 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, @@ -329,45 +320,49 @@ fn parse_command( let text = render_help_for_context(target, target_globals) raise_help(text) } - if subcommands.length() > 0 { - match find_subcommand(subcommands, arg) { - Some(sub) => { - let rest = argv[i + 1:].to_array() - let sub_matches = parse_command(sub, rest, env, child_globals) - 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, + 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, ) - 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 + 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 - finalize_matches( + let final_matches = finalize_matches( cmd, args, groups, matches, positionals, positional_values, env_args, env, ) + validate_relationships(final_matches, args) + final_matches } ///| @@ -380,12 +375,11 @@ fn finalize_matches( positional_values : Array[String], env_args : Array[Arg], env : Map[String, String], -) -> Matches raise { +) -> 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_relationships(matches, env_args) validate_groups(args, groups, matches) validate_command_policies(cmd, matches) matches @@ -412,1447 +406,33 @@ fn command_version(cmd : Command) -> String { } ///| -fn validate_command( +fn version_text_for_long_action( cmd : Command, - args : Array[Arg], - groups : Array[ArgGroup], -) -> Unit raise ArgBuildError { - validate_group_defs(groups) - validate_group_refs(args, groups) - validate_arg_defs(args) - validate_subcommand_defs(cmd.subcommands) - validate_subcommand_required_policy(cmd) - validate_help_subcommand(cmd) - validate_version_actions(cmd) - for arg in args { - validate_arg(arg) - } - for sub in cmd.subcommands { - validate_command(sub, sub.args, command_groups(sub)) - } -} - -///| -fn validate_arg(arg : Arg) -> Unit raise ArgBuildError { - let positional = is_positional_arg(arg) - let has_option_name = arg.long is Some(_) || arg.short is Some(_) - if positional && has_option_name { - raise ArgBuildError::Unsupported( - "positional args do not support short/long", - ) - } - if !positional && !has_option_name { - raise ArgBuildError::Unsupported("flag/option args require short/long") - } - let has_positional_only = arg.index is Some(_) || arg.last - if !positional && has_positional_only { - raise ArgBuildError::Unsupported( - "positional-only settings require no short/long", - ) - } - if arg.negatable && arg_takes_value(arg) { - raise ArgBuildError::Unsupported("negatable is only supported for flags") - } - if arg_action(arg) == ArgAction::Count && arg_takes_value(arg) { - raise ArgBuildError::Unsupported("count is only supported for flags") - } - if arg_action(arg) == ArgAction::Help || arg_action(arg) == ArgAction::Version { - if arg_takes_value(arg) { - raise ArgBuildError::Unsupported("help/version actions require flags") - } - if arg.negatable { - raise ArgBuildError::Unsupported( - "help/version actions do not support negatable", - ) - } - if arg.env is Some(_) || arg.default_values is Some(_) { - raise ArgBuildError::Unsupported( - "help/version actions do not support env/defaults", - ) - } - if arg.num_args is Some(_) || arg.multiple { - raise ArgBuildError::Unsupported( - "help/version actions do not support multiple values", - ) - } - let has_option = arg.long is Some(_) || arg.short is Some(_) - if !has_option { - raise ArgBuildError::Unsupported( - "help/version actions require short/long option", - ) - } - } - if arg.num_args is Some(_) && !arg_takes_value(arg) { - raise ArgBuildError::Unsupported( - "min/max values require value-taking arguments", - ) - } - let (min, max) = arg_min_max_for_validate(arg) - if !positional && arg_takes_value(arg) && arg.num_args is Some(_) && min == 0 { - raise ArgBuildError::Unsupported("option args require at least one value") - } - let allow_multi = arg.multiple || arg_action(arg) == ArgAction::Append - if (min > 1 || (max is Some(m) && m > 1)) && !allow_multi { - raise ArgBuildError::Unsupported( - "multiple values require action=Append or num_args allowing >1", - ) - } - if arg.default_values is Some(_) && !arg_takes_value(arg) { - raise ArgBuildError::Unsupported( - "default values require value-taking arguments", - ) - } - match arg.default_values { - Some(values) if values.length() > 1 && - !arg.multiple && - arg_action(arg) != ArgAction::Append => - raise ArgBuildError::Unsupported( - "default_values require action=Append or num_args allowing >1", - ) - _ => () - } -} - -///| -fn validate_group_defs(groups : Array[ArgGroup]) -> Unit raise ArgBuildError { - let seen : Map[String, Bool] = {} - for group in groups { - if seen.get(group.name) is Some(_) { - raise ArgBuildError::Unsupported("duplicate group: \{group.name}") - } - seen[group.name] = true - } - for group in groups { - for required in group.requires { - if required == group.name { - raise ArgBuildError::Unsupported( - "group cannot require itself: \{group.name}", - ) - } - if seen.get(required) is None { - 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.get(conflict) is None { - 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 group_index : Map[String, Bool] = {} - for group in groups { - group_index[group.name] = true - } - let arg_index : Map[String, Bool] = {} - for arg in args { - arg_index[arg.name] = true - } - for arg in args { - match arg.group { - Some(name) => - if group_index.get(name) is None { - raise ArgBuildError::Unsupported("unknown group: \{name}") - } - None => () - } - } - for group in groups { - for name in group.args { - if arg_index.get(name) is None { - raise ArgBuildError::Unsupported( - "unknown group arg: \{group.name} -> \{name}", - ) - } - } - } -} - -///| -fn validate_arg_defs(args : Array[Arg]) -> Unit raise ArgBuildError { - let seen_names : Map[String, Bool] = {} - let seen_long : Map[String, Bool] = {} - let seen_short : Map[Char, Bool] = {} - for arg in args { - if seen_names.get(arg.name) is Some(_) { - raise ArgBuildError::Unsupported("duplicate arg name: \{arg.name}") - } - seen_names[arg.name] = true - for name in collect_long_names(arg) { - if seen_long.get(name) is Some(_) { - raise ArgBuildError::Unsupported("duplicate long option: --\{name}") - } - seen_long[name] = true - } - for short in collect_short_names(arg) { - if seen_short.get(short) is Some(_) { - raise ArgBuildError::Unsupported("duplicate short option: -\{short}") - } - seen_short[short] = true - } - } - 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.get(required) is None { - 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.get(conflict) is None { - 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 : Map[String, Bool] = {} - for sub in subs { - for name in collect_subcommand_names(sub) { - if seen.get(name) is Some(_) { - raise ArgBuildError::Unsupported("duplicate subcommand: \{name}") - } - seen[name] = true + 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 validate_subcommand_required_policy( +fn version_text_for_short_action( 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) { - return - } - if find_subcommand(cmd.subcommands, "help") is Some(_) { - 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 Some(_) { - return - } + short : Char, + inherited_version_short : Map[Char, String], +) -> String { for arg in cmd.args { - if arg_action(arg) == ArgAction::Version { - raise ArgBuildError::Unsupported( - "version action requires command version text", - ) - } - } -} - -///| -fn validate_command_policies(cmd : Command, matches : Matches) -> Unit raise { - 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 { - if groups.length() == 0 { - return - } - let group_presence : Map[String, Int] = {} - 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_presence.get(required).unwrap_or(0) == 0 { - raise ArgParseError::MissingGroup(required) - } - } - for conflict in group.conflicts_with { - if group_presence.get(conflict).unwrap_or(0) > 0 { - raise ArgParseError::GroupConflict( - "\{group.name} conflicts with \{conflict}", - ) - } - } - } -} - -///| -fn arg_in_group(arg : Arg, group : ArgGroup) -> Bool { - let from_arg = arg.group is Some(name) && name == group.name - from_arg || group.args.contains(arg.name) -} - -///| -fn validate_values(args : Array[Arg], matches : Matches) -> Unit raise { - 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 { - 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 { - 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 -} - -///| -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 - let srcs = matches.value_sources.get(name).unwrap_or([]) - srcs.push(source) - matches.value_sources[name] = srcs - } 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_occurrence_min(arg : Arg) -> Int { - match arg.num_args { - Some(_) => { - let (min, _) = arg_min_max(arg) - min - } - None => 1 - } -} - -///| -fn option_accepts_values(arg : Arg) -> Bool { - match arg.num_args { - Some(_) => { - let (_, max) = arg_min_max(arg) - match max { - Some(max_count) => max_count > 0 - None => true - } - } - None => true - } -} - -///| -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 mark_option_present( - matches : Matches, - arg : Arg, - source : ValueSource, -) -> Unit { - if matches.values.get(arg.name) is None { - matches.values[arg.name] = [] - } - let srcs = matches.value_sources.get(arg.name).unwrap_or([]) - srcs.push(source) - matches.value_sources[arg.name] = srcs -} - -///| -fn required_option_value_count(arg : Arg) -> Int { - match arg.num_args { - None => 0 - Some(_) => { - let (_, max) = arg_min_max(arg) - match max { - Some(max_count) if max_count <= 1 => 0 - Some(max_count) => max_count - 1 - None => -1 - } - } - } -} - -///| -fn consume_additional_option_values( - matches : Matches, - arg : Arg, - argv : Array[String], - start : Int, - long_index : Map[String, Arg], - short_index : Map[Char, Arg], -) -> Int raise ArgParseError { - let max_more = required_option_value_count(arg) - if max_more == 0 { - return 0 - } - let mut consumed = 0 - while start + consumed < argv.length() { - if max_more > 0 && consumed >= max_more { - break - } - let value = argv[start + consumed] - if should_stop_option_value(value, arg, long_index, short_index) { - break - } - assign_value(matches, arg, value, ValueSource::Argv) - consumed = consumed + 1 - } - consumed -} - -///| -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 collect_long_names(arg : Arg) -> Array[String] { - let names = [] - match arg.long { - Some(value) => { - names.push(value) - if arg.negatable && !arg_takes_value(arg) { - names.push("no-\{value}") - } - } - None => () - } - names -} - -///| -fn collect_short_names(arg : Arg) -> Array[Char] { - let names = [] - match arg.short { - Some(value) => names.push(value) - None => () - } - names -} - -///| -fn collect_subcommand_names(cmd : Command) -> Array[String] { - [cmd.name] -} - -///| -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 - } -} - -///| -fn suggest_long(name : String, long_index : Map[String, Arg]) -> String? { - let candidates = map_string_keys(long_index) - match suggest_name(name, candidates) { - Some(best) => Some("--\{best}") - None => None - } -} - -///| -fn suggest_short(short : Char, short_index : Map[Char, Arg]) -> String? { - let candidates = map_char_keys(short_index) - let input = short.to_string() - match suggest_name(input, candidates) { - Some(best) => Some("-\{best}") - None => None - } -} - -///| -fn map_string_keys(map : Map[String, Arg]) -> Array[String] { - let keys = [] - for key, _ in map { - keys.push(key) - } - keys -} - -///| -fn map_char_keys(map : Map[Char, Arg]) -> Array[String] { - let keys = [] - for key, _ in map { - keys.push(key.to_string()) - } - keys -} - -///| -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 = string_chars(a) - let bb = string_chars(b) - 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] = min3(del, ins, sub) - j2 = j2 + 1 - } - let temp = prev - prev = curr - curr = temp - i = i + 1 - } - prev[n] -} - -///| -fn string_chars(s : String) -> Array[Char] { - let out = [] - for ch in s { - out.push(ch) - } - out -} - -///| -fn min3(a : Int, b : Int, c : Int) -> Int { - let m = if a < b { a } else { b } - if c < m { - c - } else { - m - } -} - -///| -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] { - let out = [] - for arg in args { - if arg.global && (arg.long is Some(_) || arg.short is Some(_)) { - out.push(arg) - } - } - out -} - -///| -fn collect_non_global_names(args : Array[Arg]) -> Map[String, Bool] { - let names : Map[String, Bool] = {} - for arg in args { - if !arg.global { - names[arg.name] = true - } - } - names -} - -///| -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 source_from_values(sources : Array[ValueSource]?) -> ValueSource? { - match sources { - Some(items) if items.length() > 0 => highest_source(items) - _ => None - } -} - -///| -fn merge_globals_from_child( - parent : Matches, - child : Matches, - globals : Array[Arg], - child_local_non_globals : Map[String, Bool], -) -> Unit { - for arg in globals { - let name = arg.name - if child_local_non_globals.get(name) is Some(_) { - continue - } - if arg_takes_value(arg) { - let parent_vals = parent.values.get(name) - let child_vals = child.values.get(name) - let parent_srcs = parent.value_sources.get(name) - let child_srcs = 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 { - continue - } - let parent_source = source_from_values(parent_srcs) - let child_source = source_from_values(child_srcs) - 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 = [] - let merged_srcs = [] - if parent_vals is Some(pv) { - for v in pv { - merged.push(v) - } - } - if parent_srcs is Some(ps) { - for s in ps { - merged_srcs.push(s) - } - } - if child_vals is Some(cv) { - for v in cv { - merged.push(v) - } - } - if child_srcs is Some(cs) { - for s in cs { - merged_srcs.push(s) - } - } - if merged.length() > 0 { - parent.values[name] = merged - parent.value_sources[name] = merged_srcs - } - } 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() - } - if child_srcs is Some(cs) && cs.length() > 0 { - parent.value_sources[name] = cs.copy() - } - } else if parent_vals is Some(pv) && pv.length() > 0 { - parent.values[name] = pv.copy() - if parent_srcs is Some(ps) && ps.length() > 0 { - parent.value_sources[name] = ps.copy() - } - } - } - } 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() - } - if child_srcs is Some(cs) && cs.length() > 0 { - parent.value_sources[name] = cs.copy() - } - } else if parent_vals is Some(pv) && pv.length() > 0 { - parent.values[name] = pv.copy() - if parent_srcs is Some(ps) && ps.length() > 0 { - parent.value_sources[name] = ps.copy() - } - } - } - } else { - 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 propagate_globals_to_child( - parent : Matches, - child : Matches, - globals : Array[Arg], - child_local_non_globals : Map[String, Bool], -) -> Unit { - for arg in globals { - let name = arg.name - if child_local_non_globals.get(name) is Some(_) { - 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(srcs) => child.value_sources[name] = srcs.copy() - 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 => () - } - } - } -} - -///| -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) - } - } - } - sort_positionals(with_index) - let ordered = [] - for item in with_index { - let (_, arg) = item - ordered.push(arg) - } - for arg in without_index { - ordered.push(arg) - } - ordered -} - -///| -fn last_positional_index(positionals : Array[Arg]) -> Int? { - let mut i = 0 - while i < positionals.length() { - if positionals[i].last { - return Some(i) - } - i = i + 1 - } - None -} - -///| -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 -} - -///| -fn sort_positionals(items : Array[(Int, Arg)]) -> Unit { - let mut i = 1 - while i < items.length() { - let key = items[i] - let mut j = i - 1 - while j >= 0 && items[j].0 > key.0 { - items[j + 1] = items[j] - if j == 0 { - j = -1 - } else { - j = j - 1 - } - } - items[j + 1] = key - i = i + 1 - } -} - -///| -fn find_subcommand(subs : Array[Command], name : String) -> Command? { - for sub in subs { - if sub.name == name { - return Some(sub) - } - } - None -} - -///| -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 { - 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}") - } - match find_subcommand(subs, name) { - Some(sub) => { - current_globals = current_globals + collect_globals(current.args) - current = sub - subs = sub.subcommands - } - None => - raise ArgParseError::InvalidArgument("unknown subcommand: \{name}") - } - } - (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] + if arg.short is Some(value) && + value == short && + arg_action(arg) == ArgAction::Version { + return command_version(cmd) } - let value = parts[1:].to_array().join("=") - (name, Some(value)) } + 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..a65710c8d --- /dev/null +++ b/argparse/parser_globals_merge.mbt @@ -0,0 +1,266 @@ +// 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 source_from_values(sources : Array[ValueSource]?) -> ValueSource? { + match sources { + Some(items) if items.length() > 0 => highest_source(items) + _ => None + } +} + +///| +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_srcs = parent.value_sources.get(name) + let child_srcs = 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 + } + let parent_source = source_from_values(parent_srcs) + let child_source = source_from_values(child_srcs) + 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 = [] + let merged_srcs = [] + if parent_vals is Some(pv) { + for v in pv { + merged.push(v) + } + } + if parent_srcs is Some(ps) { + for s in ps { + merged_srcs.push(s) + } + } + if child_vals is Some(cv) { + for v in cv { + merged.push(v) + } + } + if child_srcs is Some(cs) { + for s in cs { + merged_srcs.push(s) + } + } + if merged.length() > 0 { + parent.values[name] = merged + parent.value_sources[name] = merged_srcs + } + } 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() + } + if child_srcs is Some(cs) && cs.length() > 0 { + parent.value_sources[name] = cs.copy() + } + } else if parent_vals is Some(pv) && pv.length() > 0 { + parent.values[name] = pv.copy() + if parent_srcs is Some(ps) && ps.length() > 0 { + parent.value_sources[name] = ps.copy() + } + } + } + } 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() + } + if child_srcs is Some(cs) && cs.length() > 0 { + parent.value_sources[name] = cs.copy() + } + } else if parent_vals is Some(pv) && pv.length() > 0 { + parent.values[name] = pv.copy() + if parent_srcs is Some(ps) && ps.length() > 0 { + parent.value_sources[name] = ps.copy() + } + } + } +} + +///| +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(srcs) => child.value_sources[name] = srcs.copy() + 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..1559ab3a1 --- /dev/null +++ b/argparse/parser_positionals.mbt @@ -0,0 +1,158 @@ +// 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) + } + } + } + sort_positionals(with_index) + let ordered = [] + for item in with_index { + let (_, arg) = item + ordered.push(arg) + } + for arg in without_index { + ordered.push(arg) + } + ordered +} + +///| +fn last_positional_index(positionals : Array[Arg]) -> Int? { + let mut i = 0 + while i < positionals.length() { + if positionals[i].last { + return Some(i) + } + i = i + 1 + } + None +} + +///| +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 +} + +///| +fn sort_positionals(items : Array[(Int, Arg)]) -> Unit { + let mut i = 1 + while i < items.length() { + let key = items[i] + let mut j = i - 1 + while j >= 0 && items[j].0 > key.0 { + items[j + 1] = items[j] + if j == 0 { + j = -1 + } else { + j = j - 1 + } + } + items[j + 1] = key + i = i + 1 + } +} 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..81943a99c --- /dev/null +++ b/argparse/parser_validate.mbt @@ -0,0 +1,518 @@ +// 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) + guard arg.index is None && + !arg.last && + arg.num_args is None && + arg.default_values is None + 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", + ) + } + } + ctx.record_arg(arg) +} + +///| +fn validate_option_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + validate_named_option_arg(arg) + guard arg.index is None && !arg.last && !arg.negatable && arg.num_args is None + validate_default_values(arg) + ctx.record_arg(arg) +} + +///| +fn validate_positional_arg( + arg : Arg, + ctx : ValidationCtx, +) -> Unit raise ArgBuildError { + guard arg.long is None && arg.short is None && !arg.negatable + 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..eaa95ff86 --- /dev/null +++ b/argparse/parser_values.mbt @@ -0,0 +1,317 @@ +// 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 + let srcs = matches.value_sources.get(name).unwrap_or([]) + srcs.push(source) + matches.value_sources[name] = srcs + } 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 index 6dc9ea171..53e30798a 100644 --- a/argparse/pkg.generated.mbti +++ b/argparse/pkg.generated.mbti @@ -64,9 +64,9 @@ 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], group? : String, required? : Bool, global? : Bool, negatable? : Bool, hidden? : Bool) -> FlagArg + 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], group? : String, required? : Bool, global? : Bool, negatable? : Bool, hidden? : Bool) -> Self +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 { @@ -88,26 +88,25 @@ 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], num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], group? : String, required? : Bool, global? : Bool, hidden? : Bool) -> OptionArg + 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], num_args? : ValueRange, allow_hyphen_values? : Bool, last? : Bool, requires? : Array[String], conflicts_with? : Array[String], group? : String, required? : Bool, global? : Bool, hidden? : Bool) -> Self +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], group? : String, required? : Bool, global? : Bool, hidden? : Bool) -> PositionalArg + 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], group? : String, required? : Bool, global? : Bool, hidden? : Bool) -> Self +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, lower_inclusive? : Bool, upper_inclusive? : Bool) -> ValueRange + fn new(lower? : Int, upper? : Int) -> ValueRange } -pub fn ValueRange::empty() -> Self -pub fn ValueRange::new(lower? : Int, upper? : Int, lower_inclusive? : Bool, upper_inclusive? : Bool) -> Self +pub fn ValueRange::new(lower? : Int, upper? : Int) -> Self pub fn ValueRange::single() -> Self pub impl Eq for ValueRange pub impl Show for ValueRange diff --git a/argparse/value_range.mbt b/argparse/value_range.mbt index 91e8f9d35..4fba32977 100644 --- a/argparse/value_range.mbt +++ b/argparse/value_range.mbt @@ -18,38 +18,22 @@ pub struct ValueRange { priv lower : Int priv upper : Int? - fn new( - lower? : Int, - upper? : Int, - lower_inclusive? : Bool, - upper_inclusive? : Bool, - ) -> ValueRange + /// Create a value-count range. + fn new(lower? : Int, upper? : Int) -> ValueRange } derive(Eq, Show) ///| -pub fn ValueRange::empty() -> ValueRange { - ValueRange(lower=0, upper=0) -} - -///| +/// Exact single-value range (`1..1`). pub fn ValueRange::single() -> ValueRange { ValueRange(lower=1, upper=1) } ///| -pub fn ValueRange::new( - lower? : Int, - upper? : Int, - lower_inclusive? : Bool = true, - upper_inclusive? : Bool = true, -) -> ValueRange { - let lower = match lower { - None => 0 - Some(lower) => if lower_inclusive { lower } else { lower + 1 } - } - let upper = match upper { - None => None - Some(upper) => Some(if upper_inclusive { upper } else { 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 } } From 415ce98191db504c57c12f1efdd6612b759799e5 Mon Sep 17 00:00:00 2001 From: zihang Date: Fri, 13 Feb 2026 17:45:09 +0800 Subject: [PATCH 4/5] refactor: one source per match --- argparse/command.mbt | 2 +- argparse/matches.mbt | 29 +----------------- argparse/parser.mbt | 2 +- argparse/parser_globals_merge.mbt | 49 ++++++++++--------------------- argparse/parser_positionals.mbt | 33 +-------------------- argparse/parser_values.mbt | 6 ++-- 6 files changed, 22 insertions(+), 99 deletions(-) diff --git a/argparse/command.mbt b/argparse/command.mbt index 37a25918e..37141072c 100644 --- a/argparse/command.mbt +++ b/argparse/command.mbt @@ -143,7 +143,7 @@ fn build_matches( Some(v) => Some(v) None => match raw.value_sources.get(name) { - Some(vs) => highest_source(vs) + Some(v) => Some(v) None => None } } diff --git a/argparse/matches.mbt b/argparse/matches.mbt index 1ebd04108..886e87c6b 100644 --- a/argparse/matches.mbt +++ b/argparse/matches.mbt @@ -30,7 +30,7 @@ pub struct Matches { subcommand : (String, Matches)? priv counts : Map[String, Int] priv flag_sources : Map[String, ValueSource] - priv value_sources : Map[String, Array[ValueSource]] + priv value_sources : Map[String, ValueSource] priv mut parsed_subcommand : (String, Matches)? } @@ -49,33 +49,6 @@ fn new_matches_parse_state() -> Matches { } } -///| -fn highest_source(sources : Array[ValueSource]) -> ValueSource? { - if sources.length() == 0 { - return None - } - let mut saw_env = false - let mut saw_default = false - for s in sources { - if s == ValueSource::Argv { - return Some(ValueSource::Argv) - } - if s == ValueSource::Env { - saw_env = true - } - if s == ValueSource::Default { - saw_default = true - } - } - if saw_env { - Some(ValueSource::Env) - } else if saw_default { - Some(ValueSource::Default) - } else { - None - } -} - ///| /// Decode a full argument struct/enum from `Matches`. pub(open) trait FromMatches { diff --git a/argparse/parser.mbt b/argparse/parser.mbt index 95ce7c2e9..557149122 100644 --- a/argparse/parser.mbt +++ b/argparse/parser.mbt @@ -124,7 +124,7 @@ fn parse_command( long_index.get("version") is None let positionals = positional_args(args) let positional_values = [] - let last_pos_idx = last_positional_index(positionals) + let last_pos_idx = positionals.search_by(arg => arg.last) let mut i = 0 let mut positional_arg_found = false while i < argv.length() { diff --git a/argparse/parser_globals_merge.mbt b/argparse/parser_globals_merge.mbt index a65710c8d..0e7716231 100644 --- a/argparse/parser_globals_merge.mbt +++ b/argparse/parser_globals_merge.mbt @@ -53,14 +53,6 @@ fn strongest_source( } } -///| -fn source_from_values(sources : Array[ValueSource]?) -> ValueSource? { - match sources { - Some(items) if items.length() > 0 => highest_source(items) - _ => None - } -} - ///| fn merge_global_value_from_child( parent : Matches, @@ -70,44 +62,31 @@ fn merge_global_value_from_child( ) -> Unit { let parent_vals = parent.values.get(name) let child_vals = child.values.get(name) - let parent_srcs = parent.value_sources.get(name) - let child_srcs = child.value_sources.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 } - let parent_source = source_from_values(parent_srcs) - let child_source = source_from_values(child_srcs) 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 = [] - let merged_srcs = [] if parent_vals is Some(pv) { for v in pv { merged.push(v) } } - if parent_srcs is Some(ps) { - for s in ps { - merged_srcs.push(s) - } - } if child_vals is Some(cv) { for v in cv { merged.push(v) } } - if child_srcs is Some(cs) { - for s in cs { - merged_srcs.push(s) - } - } if merged.length() > 0 { parent.values[name] = merged - parent.value_sources[name] = merged_srcs + parent.value_sources[name] = ValueSource::Argv } } else { let choose_child = has_child && @@ -116,13 +95,15 @@ fn merge_global_value_from_child( if child_vals is Some(cv) && cv.length() > 0 { parent.values[name] = cv.copy() } - if child_srcs is Some(cs) && cs.length() > 0 { - parent.value_sources[name] = cs.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() - if parent_srcs is Some(ps) && ps.length() > 0 { - parent.value_sources[name] = ps.copy() + match parent_source { + Some(src) => parent.value_sources[name] = src + None => () } } } @@ -133,13 +114,15 @@ fn merge_global_value_from_child( if child_vals is Some(cv) && cv.length() > 0 { parent.values[name] = cv.copy() } - if child_srcs is Some(cs) && cs.length() > 0 { - parent.value_sources[name] = cs.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() - if parent_srcs is Some(ps) && ps.length() > 0 { - parent.value_sources[name] = ps.copy() + match parent_source { + Some(src) => parent.value_sources[name] = src + None => () } } } @@ -238,7 +221,7 @@ fn propagate_globals_to_child( Some(values) => { child.values[name] = values.copy() match parent.value_sources.get(name) { - Some(srcs) => child.value_sources[name] = srcs.copy() + Some(src) => child.value_sources[name] = src None => () } } diff --git a/argparse/parser_positionals.mbt b/argparse/parser_positionals.mbt index 1559ab3a1..3dc5aee5e 100644 --- a/argparse/parser_positionals.mbt +++ b/argparse/parser_positionals.mbt @@ -25,7 +25,7 @@ fn positional_args(args : Array[Arg]) -> Array[Arg] { } } } - sort_positionals(with_index) + with_index.sort_by_key(pair => pair.0) let ordered = [] for item in with_index { let (_, arg) = item @@ -37,18 +37,6 @@ fn positional_args(args : Array[Arg]) -> Array[Arg] { ordered } -///| -fn last_positional_index(positionals : Array[Arg]) -> Int? { - let mut i = 0 - while i < positionals.length() { - if positionals[i].last { - return Some(i) - } - i = i + 1 - } - None -} - ///| fn next_positional(positionals : Array[Arg], collected : Array[String]) -> Arg? { let target = collected.length() @@ -137,22 +125,3 @@ fn is_negative_number(arg : String) -> Bool { } true } - -///| -fn sort_positionals(items : Array[(Int, Arg)]) -> Unit { - let mut i = 1 - while i < items.length() { - let key = items[i] - let mut j = i - 1 - while j >= 0 && items[j].0 > key.0 { - items[j + 1] = items[j] - if j == 0 { - j = -1 - } else { - j = j - 1 - } - } - items[j + 1] = key - i = i + 1 - } -} diff --git a/argparse/parser_values.mbt b/argparse/parser_values.mbt index eaa95ff86..1e6964bfd 100644 --- a/argparse/parser_values.mbt +++ b/argparse/parser_values.mbt @@ -98,12 +98,10 @@ fn add_value( let arr = matches.values.get(name).unwrap_or([]) arr.push(value) matches.values[name] = arr - let srcs = matches.value_sources.get(name).unwrap_or([]) - srcs.push(source) - matches.value_sources[name] = srcs + matches.value_sources[name] = source } else { matches.values[name] = [value] - matches.value_sources[name] = [source] + matches.value_sources[name] = source } } From b59017e8e6e20b649121d22117730a6422ba3b28 Mon Sep 17 00:00:00 2001 From: zihang Date: Sat, 14 Feb 2026 17:51:56 +0800 Subject: [PATCH 5/5] fix: validation --- argparse/parser_validate.mbt | 42 ++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/argparse/parser_validate.mbt b/argparse/parser_validate.mbt index 81943a99c..be8344e3f 100644 --- a/argparse/parser_validate.mbt +++ b/argparse/parser_validate.mbt @@ -150,10 +150,16 @@ fn validate_flag_arg( ctx : ValidationCtx, ) -> Unit raise ArgBuildError { validate_named_option_arg(arg) - guard arg.index is None && - !arg.last && - arg.num_args is None && - arg.default_values is None + 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( @@ -171,6 +177,11 @@ fn validate_flag_arg( ) } } + if arg.default_values is Some(_) { + raise ArgBuildError::Unsupported( + "default values require value-taking arguments", + ) + } ctx.record_arg(arg) } @@ -180,7 +191,19 @@ fn validate_option_arg( ctx : ValidationCtx, ) -> Unit raise ArgBuildError { validate_named_option_arg(arg) - guard arg.index is None && !arg.last && !arg.negatable && arg.num_args is None + 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) } @@ -190,7 +213,14 @@ fn validate_positional_arg( arg : Arg, ctx : ValidationCtx, ) -> Unit raise ArgBuildError { - guard arg.long is None && arg.short is None && !arg.negatable + 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(