diff --git a/argparse/argparse_blackbox_test.mbt b/argparse/argparse_blackbox_test.mbt deleted file mode 100644 index ad67b82e91..0000000000 --- a/argparse/argparse_blackbox_test.mbt +++ /dev/null @@ -1,2739 +0,0 @@ -// Copyright 2026 International Digital Economy Academy -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -///| -test "render help snapshot with groups and hidden entries" { - let cmd = @argparse.Command( - "render", - groups=[ - ArgGroup("mode", required=true, multiple=false, args=[ - "fast", "slow", "path", - ]), - ], - subcommands=[ - Command("run", about="run"), - Command("hidden", about="hidden", hidden=true), - ], - flags=[ - FlagArg("fast", short='f', long="fast"), - FlagArg("slow", long="slow", hidden=true), - FlagArg("cache", long="cache", negatable=true, about="cache"), - ], - options=[ - OptionArg( - "path", - short='p', - long="path", - env="PATH_ENV", - default_values=["a", "b"], - required=true, - ), - ], - positionals=[ - PositionArg("target", num_args=@argparse.ValueRange::single()), - PositionArg("rest", num_args=ValueRange(lower=0)), - PositionArg("secret", hidden=true), - ], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: render --path [options] [rest...] [command] - #| - #|Commands: - #| run run - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| target - #| rest... - #| - #|Options: - #| -h, --help Show help information. - #| -f, --fast - #| --[no-]cache cache - #| -p, --path [env: PATH_ENV] [default: a, b] - #| - #|Groups: - #| mode [required] [exclusive] -f, --fast, -p, --path - #| - ), - ) -} - -///| -test "render help conversion coverage snapshot" { - let cmd = @argparse.Command( - "shape", - groups=[ArgGroup("grp", args=["f", "opt", "pos"])], - flags=[ - FlagArg( - "f", - short='f', - about="f", - env="F_ENV", - requires=["opt"], - global=true, - hidden=true, - ), - ], - options=[ - OptionArg( - "opt", - short='o', - about="opt", - default_values=["x", "y"], - env="OPT_ENV", - allow_hyphen_values=true, - required=true, - global=true, - hidden=true, - conflicts_with=["pos"], - ), - ], - positionals=[ - PositionArg( - "pos", - about="pos", - env="POS_ENV", - default_values=["p1", "p2"], - num_args=ValueRange(lower=0, upper=2), - allow_hyphen_values=true, - requires=["opt"], - conflicts_with=["f"], - global=true, - hidden=true, - ), - ], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: shape - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) -} - -///| -test "count flags and sources with pattern matching" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("verbose", short='v', long="verbose", action=Count), - ]) - let matches = cmd.parse(argv=["-v", "-v", "-v"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.flag_counts is { "verbose": 3, .. }) - assert_true(matches.sources is { "verbose": Argv, .. }) -} - -///| -test "global option merges parent and child values" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "profile", - short='p', - long="profile", - action=Append, - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse( - argv=["--profile", "parent", "run", "--profile", "child"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(matches.values is { "profile": ["parent", "child"], .. }) - assert_true(matches.sources is { "profile": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.values is { "profile": ["parent", "child"], .. }, - ) -} - -///| -test "global requires is validated after parent-child merge" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("mode", long="mode", requires=["config"], global=true), - OptionArg("config", long="config", global=true), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse( - argv=["--config", "a.toml", "run", "--mode", "fast"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true( - parsed.values is { "config": ["a.toml"], "mode": ["fast"], .. } && - parsed.subcommand is Some(("run", sub)) && - sub.values is { "config": ["a.toml"], "mode": ["fast"], .. }, - ) -} - -///| -test "global append keeps parent argv over child env/default" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "profile", - long="profile", - action=Append, - env="PROFILE", - default_values=["def"], - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ - "PROFILE": "env", - }) catch { - _ => panic() - } - assert_true(matches.values is { "profile": ["parent"], .. }) - assert_true(matches.sources is { "profile": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.values is { "profile": ["parent"], .. } && - sub.sources is { "profile": Argv, .. }, - ) -} - -///| -test "global scalar keeps parent argv over child env/default" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "profile", - long="profile", - env="PROFILE", - default_values=["def"], - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ - "PROFILE": "env", - }) catch { - _ => panic() - } - assert_true(matches.values is { "profile": ["parent"], .. }) - assert_true(matches.sources is { "profile": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.values is { "profile": ["parent"], .. } && - sub.sources is { "profile": Argv, .. }, - ) -} - -///| -test "global count merges parent and child occurrences" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', action=Count, global=true)], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["-v", "run", "-v", "-v"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flag_counts is { "verbose": 3, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flag_counts is { "verbose": 3, .. }, - ) -} - -///| -test "global count keeps parent argv over child env fallback" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - short='v', - long="verbose", - action=Count, - env="VERBOSE", - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["-v", "run"], env={ "VERBOSE": "1" }) catch { - _ => panic() - } - assert_true(matches.flag_counts is { "verbose": 1, .. }) - assert_true(matches.sources is { "verbose": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flag_counts is { "verbose": 1, .. } && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "global flag keeps parent argv over child env fallback" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", env="VERBOSE", global=true)], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["--verbose", "run"], env={ "VERBOSE": "0" }) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.sources is { "verbose": Argv, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flags is { "verbose": true, .. } && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "default subcommand dispatches through normal child parsing" { - let tui = @argparse.Command( - "tui", - about="Start interactive UI", - flags=[FlagArg("trace", short='t')], - options=[OptionArg("theme", long="theme")], - positionals=[PositionArg("workspace")], - ) - let mcp = @argparse.Command("mcp", about="Run MCP server", options=[ - OptionArg("port", long="port"), - ]) - let cmd = @argparse.Command( - "openseek", - options=[OptionArg("config", long="config", global=true)], - flags=[FlagArg("verbose", short='v', action=Count, global=true)], - subcommands=[tui, mcp], - default_subcommand="tui", - ) - - let bare = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } - assert_true(bare.subcommand is Some(("tui", _))) - - let defaulted = cmd.parse( - argv=["--config", "config.toml", "--theme", "dark", "workspace"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(defaulted.values is { "config": ["config.toml"], .. }) - assert_true( - defaulted.subcommand is Some(("tui", sub)) && - sub.values - is { - "config": ["config.toml"], - "theme": ["dark"], - "workspace": ["workspace"], - .. - }, - ) - - let short_global = cmd.parse(argv=["-v", "--theme", "dark"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_global.flag_counts is { "verbose": 1, .. }) - assert_true( - short_global.subcommand is Some(("tui", sub)) && - sub.values is { "theme": ["dark"], .. } && - sub.flag_counts is { "verbose": 1, .. }, - ) - - let mixed_short = cmd.parse(argv=["-vt"], env=empty_env()) catch { - _ => panic() - } - assert_true(mixed_short.flag_counts is { "verbose": 1, .. }) - assert_true( - mixed_short.subcommand is Some(("tui", sub)) && - sub.flags is { "trace": true, .. } && - sub.flag_counts is { "verbose": 1, .. }, - ) - - let explicit = cmd.parse(argv=["mcp", "--port", "9000"], env=empty_env()) catch { - _ => panic() - } - assert_true( - explicit.subcommand is Some(("mcp", sub)) && - sub.values is { "port": ["9000"], .. }, - ) -} - -///| -test "default subcommand gives exact subcommands precedence over positionals" { - let cmd = @argparse.Command( - "openseek", - subcommands=[ - Command("tui", positionals=[PositionArg("workspace")]), - Command("mcp"), - ], - default_subcommand="tui", - ) - - let explicit = cmd.parse(argv=["mcp"], env=empty_env()) catch { _ => panic() } - assert_true(explicit.subcommand is Some(("mcp", _))) - - let positional = cmd.parse(argv=["project"], env=empty_env()) catch { - _ => panic() - } - assert_true( - positional.subcommand is Some(("tui", sub)) && - sub.values is { "workspace": ["project"], .. }, - ) - - let explicit_default = cmd.parse(argv=["tui", "mcp"], env=empty_env()) catch { - _ => panic() - } - assert_true( - explicit_default.subcommand is Some(("tui", sub)) && - sub.values is { "workspace": ["mcp"], .. }, - ) - - let after_dash_dash = cmd.parse(argv=["--", "mcp"], env=empty_env()) catch { - _ => panic() - } - assert_true( - after_dash_dash.subcommand is Some(("tui", sub)) && - sub.values is { "workspace": ["mcp"], .. }, - ) -} - -///| -test "default subcommand help annotation and child error context" { - let cmd = @argparse.Command( - "openseek", - options=[OptionArg("config", long="config", about="config", global=true)], - subcommands=[ - Command("tui", about="Start interactive UI", options=[ - OptionArg("theme", long="theme", about="theme"), - ]), - Command("mcp", about="Run MCP server"), - ], - default_subcommand="tui", - ) - - inspect( - cmd.render_help(), - content=( - #|Usage: openseek [options] [command] - #| - #|Commands: - #| tui Start interactive UI (default) - #| mcp Run MCP server - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| --config config - #| - ), - ) - - try cmd.parse(argv=["--unknown"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--unknown' found - #| - #|Usage: openseek tui [options] - #| - #|Start interactive UI - #| - #|Options: - #| -h, --help Show help information. - #| --config config - #| --theme theme - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand cannot follow positional arguments" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ - Command("run"), - ]) - try cmd.parse(argv=["raw", "run"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: subcommand 'run' cannot be used with positional arguments - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global count source keeps env across subcommand merge" { - let child = @argparse.Command("run") - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - short='v', - long="verbose", - action=Count, - env="VERBOSE", - global=true, - ), - ], - subcommands=[child], - ) - - let matches = cmd.parse(argv=["run"], env={ "VERBOSE": "1" }) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.flag_counts is { "verbose": 1, .. }) - assert_true(matches.sources is { "verbose": Env, .. }) - assert_true( - matches.subcommand is Some(("run", sub)) && - sub.flag_counts is { "verbose": 1, .. } && - sub.sources is { "verbose": Env, .. }, - ) -} - -///| -test "help subcommand styles and errors" { - let leaf = @argparse.Command("echo", about="echo") - let cmd = @argparse.Command("demo", subcommands=[leaf]) - - inspect( - leaf.render_help(), - content=( - #|Usage: echo - #| - #|echo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [command] - #| - #|Commands: - #| echo echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - - try cmd.parse(argv=["help", "--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected help argument: --bad - #| - #|Usage: demo [command] - #| - #|Commands: - #| echo echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["help", "missing"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unknown subcommand: missing - #| - #|Usage: demo [command] - #| - #|Commands: - #| echo echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand help includes inherited global options" { - let leaf = @argparse.Command("echo", about="echo") - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - short='v', - long="verbose", - about="Enable verbose mode", - global=true, - ), - ], - subcommands=[leaf], - ) - - try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--bad' found - #| - #|Usage: demo echo [options] - #| - #|echo - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose Enable verbose mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand suggestions are only reported for parse failures" { - let cmd = @argparse.Command( - "demo", - positionals=[PositionArg("input", about="input file")], - subcommands=[Command("serve", about="serve")], - ) - let positional = cmd.parse(argv=["serv"], env=empty_env()) catch { - _ => panic() - } - assert_true(positional.values is { "input": ["serv"], .. }) - assert_true(positional.subcommand is None) - - try cmd.parse(argv=["input.txt", "serv"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'serv' for '' found; no more were expected - #| - #| tip: a similar subcommand exists: 'serve' - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input input file - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } - - let no_positionals = @argparse.Command("demo", subcommands=[ - Command("serve", about="serve"), - ]) - try no_positionals.parse(argv=["hel"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'hel' found; no more were expected - #| - #| tip: a similar subcommand exists: 'help' - #| - #|Usage: demo [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["help", "serv"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unknown subcommand: serv - #| - #| tip: a similar subcommand exists: 'serve' - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input input file - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "help subcommand suggestions exclude hidden commands" { - let cmd = @argparse.Command("demo", subcommands=[ - Command("serve", about="serve"), - Command("secret", about="secret", hidden=true), - ]) - try cmd.parse(argv=["help", "secrt"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unknown subcommand: secrt - #| - #|Usage: demo [command] - #| - #|Commands: - #| serve serve - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "unknown argument suggestions are exposed" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("verbose", short='v', long="verbose"), - ]) - - try cmd.parse(argv=["--verbse"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbse' found - #| - #| tip: a similar argument exists: '--verbose' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["-x"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-x' found - #| - #| tip: a similar argument exists: '-v' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--zzzzzzzzzz"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--zzzzzzzzzz' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "long and short value parsing branches" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("count", short='c', long="count"), - ]) - - let long_inline = cmd.parse(argv=["--count=2"], env=empty_env()) catch { - _ => panic() - } - assert_true(long_inline.values is { "count": ["2"], .. }) - - let short_inline = cmd.parse(argv=["-c=3"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_inline.values is { "count": ["3"], .. }) - - let short_attached = cmd.parse(argv=["-c4"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_attached.values is { "count": ["4"], .. }) - - try cmd.parse(argv=["--count"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--count' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -c, --count - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["-c"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '-c' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -c, --count - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "append option action is publicly selectable" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag", action=Append), - ]) - let appended = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(appended.values is { "tag": ["a", "b"], .. }) - assert_true(appended.sources is { "tag": Argv, .. }) -} - -///| -test "negation parsing and invalid negation forms" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("cache", long="cache", negatable=true)], - options=[OptionArg("path", long="path")], - ) - - let off = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(off.flags is { "cache": false, .. }) - assert_true(off.sources is { "cache": Argv, .. }) - - try cmd.parse(argv=["--no-path"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--no-path' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --path - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--no-missing"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--no-missing' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --path - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--no-cache=1"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--no-cache=1' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --path - #| - ), - ) - } noraise { - _ => panic() - } - - let count_cmd = @argparse.Command("demo", flags=[ - FlagArg("verbose", long="verbose", action=Count, negatable=true), - ]) - let reset = count_cmd.parse( - argv=["--verbose", "--no-verbose"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(reset.flags is { "verbose": false, .. }) - assert_true(reset.flag_counts is { "verbose"? : None, .. }) - assert_true(reset.sources is { "verbose": Argv, .. }) -} - -///| -test "positionals dash handling and separator" { - let force_cmd = @argparse.Command("demo", positionals=[ - PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), - ]) - let forced = force_cmd.parse(argv=["a", "--x", "-y"], env=empty_env()) catch { - _ => panic() - } - assert_true(forced.values is { "tail": ["a", "--x", "-y"], .. }) - - let dashed = force_cmd.parse(argv=["--", "p", "q"], env=empty_env()) catch { - _ => panic() - } - assert_true(dashed.values is { "tail": ["p", "q"], .. }) - - let negative_cmd = @argparse.Command("demo", positionals=[PositionArg("n")]) - let negative = negative_cmd.parse(argv=["-9"], env=empty_env()) catch { - _ => panic() - } - assert_true(negative.values is { "n": ["-9"], .. }) - - try negative_cmd.parse(argv=["x", "y"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'y' for '' found; no more were expected - #| - #|Usage: demo [n] - #| - #|Arguments: - #| n - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "variadic positional keeps accepting hyphen values after first token" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), - ]) - let parsed = cmd.parse(argv=["a", "-b", "--mystery"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "tail": ["a", "-b", "--mystery"], .. }) -} - -///| -test "bounded positional does not greedily consume later required values" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=ValueRange(lower=1, upper=2)), - PositionArg("second", num_args=@argparse.ValueRange::single()), - ]) - - let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } - assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) - - let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) -} - -///| -test "indexed non-last positional allows explicit single num_args" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=@argparse.ValueRange::single()), - PositionArg("second", num_args=@argparse.ValueRange::single()), - ]) - - let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) -} - -///| -test "bounded positional can leave later optional positional empty" { - let parsed = @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=0, upper=2)), - PositionArg("y"), - ]).parse(argv=["a"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "x": ["a"], "y"? : None, .. }) -} - -///| -test "env parsing for settrue setfalse count and invalid values" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("on", long="on", action=SetTrue, env="ON"), - FlagArg("off", long="off", action=SetFalse, env="OFF"), - FlagArg("v", long="v", action=Count, env="V"), - ]) - - let parsed = cmd.parse(argv=[], env={ "ON": "true", "OFF": "true", "V": "3" }) catch { - _ => panic() - } - assert_true(parsed.flags is { "on": true, "off": false, "v": true, .. }) - assert_true(parsed.flag_counts is { "v": 3, .. }) - assert_true(parsed.sources is { "on": Env, "off": Env, "v": Env, .. }) - - try cmd.parse(argv=[], env={ "ON": "bad" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=[], env={ "OFF": "bad" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=[], env={ "V": "bad" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value 'bad' for count; expected a non-negative integer - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=[], env={ "V": "-1" }) catch { - err => - inspect( - err, - content=( - #|error: invalid value '-1' for count; expected a non-negative integer - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --on [env: ON] - #| --off [env: OFF] - #| --v [env: V] - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "defaults and value range helpers through public API" { - let defaults = @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", action=Append, default_values=["a", "b"]), - OptionArg("one", long="one", default_values=["x"]), - ]) - let by_default = defaults.parse(argv=[], env=empty_env()) catch { - _ => panic() - } - assert_true(by_default.values is { "mode": ["a", "b"], "one": ["x"], .. }) - assert_true(by_default.sources is { "mode": Default, "one": Default, .. }) - - let upper_only = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag", action=Append), - ]) - let upper_parsed = upper_only.parse( - argv=["--tag", "a", "--tag", "b", "--tag", "c"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(upper_parsed.values is { "tag": ["a", "b", "c"], .. }) - - let lower_only = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag"), - ]) - let lower_absent = lower_only.parse(argv=[], env=empty_env()) catch { - _ => panic() - } - assert_true(lower_absent.values is { "tag"? : None, .. }) - - try lower_only.parse(argv=["--tag"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--tag' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } - - let single_range = @argparse.ValueRange::single() - inspect( - single_range, - content=( - #|{lower: 1, upper: Some(1)} - ), - ) -} - -///| -test "options consume exactly one value per occurrence" { - let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) - let parsed = cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "tag": ["a"], .. }) - assert_true(parsed.sources is { "tag": Argv, .. }) - - try cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'b' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "set options reject duplicate occurrences" { - let cmd = @argparse.Command("demo", options=[OptionArg("mode", long="mode")]) - try cmd.parse(argv=["--mode", "a", "--mode", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '--mode' cannot be used multiple times - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "append options collect values across repeated occurrences" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("arg", long="arg", action=Append), - ]) - let parsed = cmd.parse(argv=["--arg", "x", "--arg", "y"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "arg": ["x", "y"], .. }) - assert_true(parsed.sources is { "arg": Argv, .. }) -} - -///| -test "option parsing stops at the next option token" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("arg", short='a', long="arg")], - ) - - let stopped = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(stopped.values is { "arg": ["x"], .. }) - assert_true(stopped.flags is { "verbose": true, .. }) - - try cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'y' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| -a, --arg - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'y' found; no more were expected - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| -a, --arg - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "options always require a value" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("opt", long="opt")], - ) - try cmd.parse(argv=["--opt", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--opt' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| --opt - #| - ), - ) - } noraise { - _ => panic() - } - - let zero_value_required = @argparse.Command("demo", options=[ - OptionArg("opt", long="opt", required=true), - ]).parse(argv=["--opt", "x"], env=empty_env()) catch { - _ => panic() - } - assert_true(zero_value_required.values is { "opt": ["x"], .. }) -} - -///| -test "option values reject hyphen tokens unless allow_hyphen_values is enabled" { - let strict = @argparse.Command("demo", options=[ - OptionArg("pattern", long="pattern"), - ]) - let mut rejected = false - try strict.parse(argv=["--pattern", "-file"], env=empty_env()) catch { - err => { - inspect( - err, - content=( - #|error: a value is required for '--pattern' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --pattern - #| - ), - ) - rejected = true - } - } noraise { - _ => rejected = true - } - assert_true(rejected) - - let permissive = @argparse.Command("demo", options=[ - OptionArg("pattern", long="pattern", allow_hyphen_values=true), - ]) - let parsed = permissive.parse(argv=["--pattern", "-file"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "pattern": ["-file"], .. }) - assert_true(parsed.sources is { "pattern": Argv, .. }) -} - -///| -test "default argv path is reachable" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("rest", num_args=ValueRange(lower=0), allow_hyphen_values=true), - ]) - let _ = cmd.parse(env=empty_env()) catch { _ => panic() } -} - -///| -test "builtin and custom help/version dispatch edge paths" { - let custom_help = @argparse.Command("demo", flags=[ - FlagArg("custom_help", short='h', long="help", about="custom help"), - ]) - let help_short = custom_help.parse(argv=["-h"], env=empty_env()) catch { - _ => panic() - } - let help_long = custom_help.parse(argv=["--help"], env=empty_env()) catch { - _ => panic() - } - assert_true(help_short.flags is { "custom_help": true, .. }) - assert_true(help_long.flags is { "custom_help": true, .. }) - inspect( - custom_help.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help custom help - #| - ), - ) - - let custom_version = @argparse.Command("demo", version="1.0", flags=[ - FlagArg("custom_version", short='V', long="version", about="custom version"), - ]) - let version_short = custom_version.parse(argv=["-V"], env=empty_env()) catch { - _ => panic() - } - let version_long = custom_version.parse(argv=["--version"], env=empty_env()) catch { - _ => panic() - } - assert_true(version_short.flags is { "custom_version": true, .. }) - assert_true(version_long.flags is { "custom_version": true, .. }) - inspect( - custom_version.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version custom version - #| - ), - ) - - let versioned = @argparse.Command("demo", version="1.2.3") - inspect( - versioned.render_help(), - content=( - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - - try versioned.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } - - let long_help = @argparse.Command("demo", flags=[ - FlagArg("assist", long="assist", action=Help), - ]) - inspect( - long_help.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --assist - #| - ), - ) - - let short_help = @argparse.Command("demo", flags=[ - FlagArg("assist", short='?', action=Help), - ]) - inspect( - short_help.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -?, --assist - #| - ), - ) -} - -///| -test "subcommand lookup falls back to positional value" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ - Command("run"), - ]) - let parsed = cmd.parse(argv=["raw"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.values is { "input": ["raw"], .. }) - assert_true(parsed.subcommand is None) -} - -///| -test "group requires/conflicts can target argument names" { - let requires_cmd = @argparse.Command( - "demo", - groups=[ArgGroup("mode", args=["fast"], requires=["config"])], - flags=[FlagArg("fast", long="fast")], - options=[OptionArg("config", long="config")], - ) - - let ok = requires_cmd.parse( - argv=["--fast", "--config", "cfg.toml"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(ok.flags is { "fast": true, .. }) - assert_true(ok.values is { "config": ["cfg.toml"], .. }) - - try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'config' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --config - #| - #|Groups: - #| mode --fast - #| - ), - ) - } noraise { - _ => panic() - } - - let conflicts_cmd = @argparse.Command( - "demo", - groups=[ArgGroup("mode", args=["fast"], conflicts_with=["config"])], - flags=[FlagArg("fast", long="fast")], - options=[OptionArg("config", long="config")], - ) - - try - conflicts_cmd.parse( - argv=["--fast", "--config", "cfg.toml"], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: group conflict mode conflicts with config - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --config - #| - #|Groups: - #| mode --fast - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "group without members has no parse effect" { - let cmd = @argparse.Command("demo", groups=[ArgGroup("known")], flags=[ - FlagArg("x", long="x"), - ]) - let parsed = cmd.parse(argv=["--x"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.flags is { "x": true, .. }) - let help = cmd.render_help() - assert_true(help.has_prefix("Usage: demo [options]")) -} - -///| -test "empty groups without presence do not fail" { - let grouped_ok = @argparse.Command( - "demo", - groups=[ArgGroup("left", args=["l"]), ArgGroup("right", args=["r"])], - flags=[FlagArg("l", long="left"), FlagArg("r", long="right")], - ) - let parsed = grouped_ok.parse(argv=["--left"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "l": true, .. }) -} - -///| -test "help rendering edge paths stay stable" { - let required_many = @argparse.Command("demo", positionals=[ - PositionArg("files", num_args=ValueRange(lower=1)), - ]) - let required_help = required_many.render_help() - assert_true(required_help.has_prefix("Usage: demo ")) - - let short_only_builtin = @argparse.Command("demo", options=[ - OptionArg("helpopt", long="help"), - ]) - let short_only_text = short_only_builtin.render_help() - assert_true(short_only_text.has_prefix("Usage: demo")) - try short_only_builtin.parse(argv=["--help"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--help' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h Show help information. - #| --help - #| - ), - ) - } noraise { - _ => panic() - } - - let long_only_builtin = @argparse.Command("demo", flags=[ - FlagArg("custom_h", short='h'), - ]) - let long_only_text = long_only_builtin.render_help() - assert_true(long_only_text.has_prefix("Usage: demo")) - let custom_h = long_only_builtin.parse(argv=["-h"], env=empty_env()) catch { - _ => panic() - } - assert_true(custom_h.flags is { "custom_h": true, .. }) - - let empty_options = @argparse.Command( - "demo", - disable_help_flag=true, - disable_version_flag=true, - ) - let empty_options_help = empty_options.render_help() - assert_true(empty_options_help.has_prefix("Usage: demo")) - - let implicit_group = @argparse.Command("demo", positionals=[ - PositionArg("item"), - ]) - let implicit_group_help = implicit_group.render_help() - assert_true(implicit_group_help.has_prefix("Usage: demo [item]")) - - let sub_visible = @argparse.Command("demo", disable_help_subcommand=true, subcommands=[ - Command("run"), - ]) - let sub_help = sub_visible.render_help() - assert_true(sub_help.has_prefix("Usage: demo [command]")) -} - -///| -test "unified error message formatting remains stable" { - let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--tag"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--tag' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "options require one value per occurrence" { - let with_value = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag"), - ]).parse(argv=["--tag", "x"], env=empty_env()) catch { - _ => panic() - } - assert_true(with_value.values is { "tag": ["x"], .. }) - - try - @argparse.Command("demo", options=[OptionArg("tag", long="tag")]).parse( - argv=["--tag"], - env=empty_env(), - ) - catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--tag' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --tag - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "short options require one value before next option token" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", short='v')], options=[ - OptionArg("x", short='x'), - ]) - let ok = cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { - _ => panic() - } - assert_true(ok.values is { "x": ["a"], .. }) - assert_true(ok.flags is { "verbose": true, .. }) - - try cmd.parse(argv=["-x", "-v"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '-x' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose - #| -x, --x - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "version action dispatches on custom long and short flags" { - let cmd = @argparse.Command("demo", version="2.0.0", flags=[ - FlagArg("show_long", long="show-version", action=Version), - FlagArg("show_short", short='S', action=Version), - ]) - - inspect( - cmd.render_help(), - content=( - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| --show-version - #| -S, --show_short - #| - ), - ) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| --show-version - #| -S, --show_short - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "global version action keeps parent version text in subcommand context" { - let cmd = @argparse.Command( - "demo", - version="1.0.0", - flags=[ - FlagArg( - "show_version", - short='S', - long="show-version", - action=Version, - global=true, - ), - ], - subcommands=[Command("run")], - ) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo [options] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| -S, --show-version - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo run [options] - #| - #|Options: - #| -h, --help Show help information. - #| -S, --show-version - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand help puts required options in usage" { - let cmd = @argparse.Command("demo", subcommands=[ - Command( - "run", - about="Run a file", - options=[OptionArg("mode", short='m', required=true)], - positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())], - ), - ]) - - try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo run --mode - #| - #|Run a file - #| - #|Arguments: - #| file - #| - #|Options: - #| -h, --help Show help information. - #| -m, --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "required_option_usage covers option/flag/hidden/short-only" { - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg("verbose", short='v', long="verbose", required=true), - FlagArg("secret", short='s', long="secret", required=true, hidden=true), - ], - options=[ - OptionArg("mode", long="mode", required=true), - OptionArg("tag", short='t', long="", required=true), - OptionArg("optional", long="optional"), - ], - positionals=[ - PositionArg("required_pos", num_args=@argparse.ValueRange::single()), - ], - ) - let help = cmd.render_help() - assert_true( - help.has_prefix( - "Usage: demo --verbose --mode -t [options] ", - ), - ) -} - -///| -test "required_option_usage returns empty when nothing required" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', long="verbose")], - options=[OptionArg("mode", long="mode")], - ) - let help = cmd.render_help() - assert_true(help.has_prefix("Usage: demo [options]")) -} - -///| -test "required and env-fed ranged values validate after parsing" { - let required_cmd = @argparse.Command("demo", options=[ - OptionArg("input", long="input", required=true), - ]) - try required_cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'input' - #| - #|Usage: demo --input - #| - #|Options: - #| -h, --help Show help information. - #| --input - #| - ), - ) - } noraise { - _ => panic() - } - - let env_min_cmd = @argparse.Command("demo", options=[ - OptionArg("pair", long="pair", env="PAIR"), - ]) - let env_value = env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { - _ => panic() - } - assert_true(env_value.values is { "pair": ["one"], .. }) - assert_true(env_value.sources is { "pair": Env, .. }) -} - -///| -test "positionals keep declaration order with ranged positional" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("late", num_args=ValueRange(lower=2, upper=2)), - PositionArg("first"), - PositionArg("mid"), - ]) - - let parsed = cmd.parse(argv=["a", "b", "c", "d"], env=empty_env()) catch { - _ => panic() - } - assert_true( - parsed.values is { "late": ["a", "b"], "first": ["c"], "mid": ["d"], .. }, - ) -} - -///| -test "mixed indexed and unindexed positionals keep inferred order" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first"), - PositionArg("second"), - ]) - - let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) -} - -///| -test "single positional parses without explicit index metadata" { - let parsed = @argparse.Command("demo", positionals=[PositionArg("late")]).parse( - argv=["x"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(parsed.values is { "late": ["x"], .. }) -} - -///| -test "positional num_args lower bound rejects missing argv values" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=ValueRange(lower=2, upper=3)), - ]) - - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: 'first' requires at least 2 values but only 0 were provided - #| - #|Usage: demo - #| - #|Arguments: - #| first... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "positional max clamp leaves trailing value for next positional" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("items", num_args=ValueRange(lower=0, upper=2)), - PositionArg("tail"), - ]) - - let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "items": ["a", "b"], "tail": ["c"], .. }) -} - -///| -test "options with allow_hyphen_values accept option-like single values" { - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg("verbose", long="verbose"), - FlagArg("cache", long="cache", negatable=true), - FlagArg("quiet", short='q'), - ], - options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], - ) - - let known_long = cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(known_long.values is { "arg": ["--verbose"], .. }) - assert_true(known_long.flags is { "verbose"? : None, .. }) - - let negated = cmd.parse(argv=["--arg", "--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(negated.values is { "arg": ["--no-cache"], .. }) - assert_true(negated.flags is { "cache"? : None, .. }) - - let unknown_long_value = cmd.parse( - argv=["--arg", "--mystery"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(unknown_long_value.values is { "arg": ["--mystery"], .. }) - - let known_short = cmd.parse(argv=["--arg", "-q"], env=empty_env()) catch { - _ => panic() - } - assert_true(known_short.values is { "arg": ["-q"], .. }) - assert_true(known_short.flags is { "quiet"? : None, .. }) - - let cmd_with_rest = @argparse.Command( - "demo", - options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], - positionals=[ - PositionArg( - "rest", - num_args=ValueRange(lower=0), - allow_hyphen_values=true, - ), - ], - ) - let sentinel_stop = cmd_with_rest.parse( - argv=["--arg", "x", "--", "tail"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(sentinel_stop.values is { "arg": ["x"], "rest": ["tail"], .. }) -} - -///| -test "single-value options avoid consuming additional option values" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("one", long="one")], - ) - - let parsed = cmd.parse(argv=["--one", "x", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "one": ["x"], .. }) - assert_true(parsed.flags is { "verbose": true, .. }) -} - -///| -test "missing option values are reported when next token is another option" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - options=[OptionArg("arg", long="arg")], - ) - - let ok = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(ok.values is { "arg": ["x"], .. }) - assert_true(ok.flags is { "verbose": true, .. }) - - try cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: a value is required for '--arg' but none was supplied - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| --arg - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "short-only set options use short label in duplicate errors" { - let cmd = @argparse.Command("demo", options=[OptionArg("mode", short='m')]) - try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '--mode' cannot be used multiple times - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -m, --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "unknown short suggestion can be absent" { - let cmd = @argparse.Command("demo", disable_help_flag=true, options=[ - OptionArg("name", long="name"), - ]) - - try cmd.parse(argv=["-x"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-x' found - #| - #|Usage: demo [options] - #| - #|Options: - #| --name - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "setfalse flags apply false when present" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("failfast", long="failfast", action=SetFalse), - ]) - let parsed = cmd.parse(argv=["--failfast"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "failfast": false, .. }) - assert_true(parsed.sources is { "failfast": Argv, .. }) -} - -///| -test "allow_hyphen positional treats unknown long token as value" { - let cmd = @argparse.Command("demo", flags=[FlagArg("known", long="known")], positionals=[ - PositionArg("input", allow_hyphen_values=true), - ]) - let parsed = cmd.parse(argv=["--mystery"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "input": ["--mystery"], .. }) -} - -///| -test "global value from child default is merged back to parent" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("mode", long="mode", default_values=["safe"], global=true), - OptionArg("unused", long="unused", global=true), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse(argv=["run"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.values is { "mode": ["safe"], "unused"? : None, .. }) - assert_true(parsed.sources is { "mode": Default, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["safe"], .. } && - sub.sources is { "mode": Default, .. }, - ) -} - -///| -test "env-only global is propagated to nested subcommand matches" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("level", long="", env="LEVEL", global=true)], - subcommands=[Command("run", subcommands=[Command("leaf")])], - ) - - let parsed = cmd.parse(argv=["run", "leaf"], env={ "LEVEL": "5" }) catch { - _ => panic() - } - assert_true(parsed.values is { "level": ["5"], .. }) - assert_true(parsed.sources is { "level": Env, .. }) - assert_true( - parsed.subcommand is Some(("run", sub_run)) && - sub_run.values is { "level": ["5"], .. } && - sub_run.sources is { "level": Env, .. } && - sub_run.subcommand is Some(("leaf", sub_leaf)) && - sub_leaf.values is { "level": ["5"], .. } && - sub_leaf.sources is { "level": Env, .. }, - ) -} - -///| -test "child global arg with inherited global name updates parent global" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("mode", long="mode", default_values=["safe"], global=true), - ], - subcommands=[ - Command("run", options=[OptionArg("mode", long="mode", global=true)]), - ], - ) - - let parsed = cmd.parse(argv=["run", "--mode", "fast"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true(parsed.sources is { "mode": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["fast"], .. } && - sub.sources is { "mode": Argv, .. }, - ) -} - -///| -test "child global override env/default win over inherited definition" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg( - "mode", - long="mode", - env="ROOT_MODE", - default_values=["safe"], - global=true, - ), - ], - subcommands=[ - Command("run", options=[ - OptionArg( - "mode", - long="mode", - env="RUN_MODE", - default_values=["fast"], - global=true, - ), - ]), - ], - ) - - let from_env = cmd.parse(argv=["run"], env={ - "ROOT_MODE": "root-env", - "RUN_MODE": "run-env", - }) catch { - _ => panic() - } - assert_true(from_env.values is { "mode": ["run-env"], .. }) - assert_true(from_env.sources is { "mode": Env, .. }) - assert_true( - from_env.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["run-env"], .. } && - sub.sources is { "mode": Env, .. }, - ) - - let from_default = cmd.parse(argv=["run"], env=Map([])) catch { _ => panic() } - assert_true(from_default.values is { "mode": ["fast"], .. }) - assert_true(from_default.sources is { "mode": Default, .. }) - assert_true( - from_default.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["fast"], .. } && - sub.sources is { "mode": Default, .. }, - ) -} - -///| -test "inherited argv global satisfies child required global override" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[ - Command("run", options=[ - OptionArg("mode", long="mode", required=true, global=true), - ]), - ], - ) - - let parsed = cmd.parse(argv=["--mode", "fast", "run"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true(parsed.sources is { "mode": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "mode": ["fast"], .. } && - sub.sources is { "mode": Argv, .. }, - ) -} - -///| -test "global append env value from child is merged back to parent" { - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("tag", long="tag", action=Append, env="TAG", global=true), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse(argv=["run"], env={ "TAG": "env-tag" }) catch { - _ => panic() - } - assert_true(parsed.values is { "tag": ["env-tag"], .. }) - assert_true(parsed.sources is { "tag": Env, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.values is { "tag": ["env-tag"], .. } && - sub.sources is { "tag": Env, .. }, - ) -} - -///| -test "global flag set in child argv is merged back to parent" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", global=true)], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse(argv=["run", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "verbose": true, .. }) - assert_true(parsed.sources is { "verbose": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.flags is { "verbose": true, .. } && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "global count negation after subcommand resets merged state" { - let cmd = @argparse.Command( - "demo", - flags=[ - FlagArg( - "verbose", - long="verbose", - action=Count, - negatable=true, - global=true, - ), - ], - subcommands=[Command("run")], - ) - - let parsed = cmd.parse( - argv=["--verbose", "run", "--no-verbose"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(parsed.flags is { "verbose": false, .. }) - assert_true(parsed.flag_counts.get("verbose") is None) - assert_true(parsed.sources is { "verbose": Argv, .. }) - assert_true( - parsed.subcommand is Some(("run", sub)) && - sub.flags is { "verbose": false, .. } && - sub.flag_counts.get("verbose") is None && - sub.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "global set option rejects duplicate occurrences across subcommands" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[Command("run")], - ) - try - cmd.parse(argv=["--mode", "a", "run", "--mode", "b"], env=empty_env()) - catch { - err => - inspect( - err, - content=( - #|error: argument '--mode' cannot be used multiple times - #| - #|Usage: demo [options] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| --mode - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "nested subcommands inherit finalized globals from ancestors" { - let leaf = @argparse.Command("leaf") - let mid = @argparse.Command("mid", subcommands=[leaf]) - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose", global=true)], - subcommands=[mid], - ) - - let parsed = cmd.parse(argv=["--verbose", "mid", "leaf"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "verbose": true, .. }) - assert_true( - parsed.subcommand is Some(("mid", mid_matches)) && - mid_matches.flags is { "verbose": true, .. } && - mid_matches.subcommand is Some(("leaf", leaf_matches)) && - leaf_matches.flags is { "verbose": true, .. } && - leaf_matches.sources is { "verbose": Argv, .. }, - ) -} - -///| -test "non-bmp short option token does not panic" { - let cmd = @argparse.Command("demo", flags=[FlagArg("party", short='🎉')]) - let parsed = cmd.parse(argv=["-🎉"], env=empty_env()) catch { _ => panic() } - assert_true(parsed.flags is { "party": true, .. }) -} - -///| -test "non-bmp hyphen token reports unknown argument without panic" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("value")]) - try cmd.parse(argv=["-🎉"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-🎉' found - #| - #|Usage: demo [value] - #| - #|Arguments: - #| value - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "option env values remain string values instead of flags" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", env="MODE"), - ]) - let parsed = cmd.parse(argv=[], env={ "MODE": "fast" }) catch { _ => panic() } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true(parsed.flags.get("mode") is None) - assert_true(parsed.sources is { "mode": Env, .. }) -} - -///| -test "nested global override deduplicates count merge by name" { - let leaf = @argparse.Command("leaf") - let mid = @argparse.Command( - "mid", - flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], - subcommands=[leaf], - ) - let root = @argparse.Command( - "root", - flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], - subcommands=[mid], - ) - - let parsed = root.parse(argv=["mid", "leaf", "--verbose"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flag_counts is { "verbose": 1, .. }) - assert_true( - parsed.subcommand is Some(("mid", sub_mid)) && - sub_mid.flag_counts is { "verbose": 1, .. } && - sub_mid.subcommand is Some(("leaf", sub_leaf)) && - sub_leaf.flag_counts is { "verbose": 1, .. }, - ) -} - -///| -test "nested global override keeps single set value without false duplicate error" { - let leaf = @argparse.Command("leaf") - let mid = @argparse.Command( - "mid", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[leaf], - ) - let root = @argparse.Command( - "root", - options=[OptionArg("mode", long="mode", global=true)], - subcommands=[mid], - ) - - let parsed = root.parse( - argv=["mid", "leaf", "--mode", "fast"], - env=empty_env(), - ) catch { - _ => panic() - } - assert_true(parsed.values is { "mode": ["fast"], .. }) - assert_true( - parsed.subcommand is Some(("mid", sub_mid)) && - sub_mid.values is { "mode": ["fast"], .. } && - sub_mid.subcommand is Some(("leaf", sub_leaf)) && - sub_leaf.values is { "mode": ["fast"], .. }, - ) -} - -///| -test "positional range 0..1 renders as single optional value" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("x", num_args=ValueRange(lower=0, upper=1)), - ]) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [x] - #| - #|Arguments: - #| x - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) -} - -///| -test "Debug for argparse enums" { - @debug.debug_inspect(@argparse.FlagAction::SetTrue, content="SetTrue") - @debug.debug_inspect(@argparse.FlagAction::Count, content="Count") - @debug.debug_inspect(@argparse.OptionAction::Set, content="Set") - @debug.debug_inspect(@argparse.OptionAction::Append, content="Append") -} diff --git a/argparse/argparse_coverage_test.mbt b/argparse/argparse_coverage_test.mbt deleted file mode 100644 index 0e7e030677..0000000000 --- a/argparse/argparse_coverage_test.mbt +++ /dev/null @@ -1,419 +0,0 @@ -// 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. - -// Black-box tests that exercise argparse behaviors not covered by the existing -// suites: required-group usage rendering, value-count validation, global -// argument merging across subcommands, and assorted error-message edge paths. - -///| -test "required group with only hidden members reports a plain message" { - let cmd = @argparse.Command( - "demo", - groups=[ArgGroup("mode", required=true, args=["fast"])], - flags=[FlagArg("fast", long="fast", hidden=true)], - ) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument group was not provided: 'mode' - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "required group usage lists every member shape" { - let cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("g", required=true, args=["opt", "posm", "poss", "sf", "ef"]), - ], - flags=[FlagArg("sf", short='s', long=""), FlagArg("ef", long="", env="EF")], - options=[OptionArg("opt", long="opt")], - positionals=[ - PositionArg("posm", num_args=ValueRange(lower=0)), - PositionArg("poss", num_args=ValueRange(lower=0, upper=1)), - ], - ) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required arguments were not provided: - #| <--opt |||-s|ef> - #| - #|Usage: demo [options] [posm...] [poss] - #| - #|Arguments: - #| posm... - #| poss - #| - #|Options: - #| -h, --help Show help information. - #| -s - #| --opt - #| - #|Groups: - #| g [required] -s, ef, --opt , posm, poss - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "positional default values exceeding num_args upper bound are rejected" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("tags", num_args=ValueRange(lower=0, upper=2), default_values=[ - "a", "b", "c", - ]), - ]) - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: 'tags' allows at most 2 values but 3 were provided - #| - #|Usage: demo [tags...] - #| - #|Arguments: - #| tags... [default: a, b, c] - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "earlier variadic positional reserves values for a later required one" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("head", num_args=ValueRange(lower=0)), - PositionArg("tail", num_args=ValueRange(lower=2)), - ]) - try cmd.parse(argv=["only"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: 'tail' requires at least 2 values but only 1 were provided - #| - #|Usage: demo [head...] - #| - #|Arguments: - #| head... - #| tail... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "extra values past a bounded variadic positional report the variadic label" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("items", num_args=ValueRange(lower=0, upper=2)), - ]) - let ok = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } - assert_true(ok.values is { "items": ["a", "b"], .. }) - try cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'c' for '' found; no more were expected - #| - #|Usage: demo [items...] - #| - #|Arguments: - #| items... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "duplicate short-only set option reports the short flag label" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("mode", short='m', long=""), - ]) - try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '-m' cannot be used multiple times - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -m - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "boolean env flags accept falsy values" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("on", long="on", action=SetTrue, env="ON"), - FlagArg("off", long="off", action=SetFalse, env="OFF"), - ]) - let parsed = cmd.parse(argv=[], env={ "ON": "0", "OFF": "no" }) catch { - _ => panic() - } - assert_true(parsed.flags is { "on": false, "off": true, .. }) - assert_true(parsed.sources is { "on": Env, "off": Env, .. }) -} - -///| -test "unknown long flag with an empty name has no suggestion" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) - try cmd.parse(argv=["--=value"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "compatible global flag can be redeclared in a subcommand" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("v", short='v', long="", global=true)], - subcommands=[ - Command("child", flags=[FlagArg("v", short='v', long="", global=true)]), - ], - ) - let parsed = cmd.parse(argv=["-v", "child"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.flags is { "v": true, .. }) - assert_true( - parsed.subcommand is Some(("child", sub)) && sub.flags is { "v": true, .. }, - ) -} - -///| -test "help subcommand cannot follow positional arguments" { - let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ - Command("run"), - ]) - try cmd.parse(argv=["raw", "help"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: subcommand 'help' cannot be used with positional arguments - #| - #|Usage: demo [input] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| input - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "render_help lists positional members inside argument groups" { - let cmd = @argparse.Command( - "demo", - groups=[ArgGroup("inputs", args=["src"])], - positionals=[PositionArg("src")], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [src] - #| - #|Arguments: - #| src - #| - #|Options: - #| -h, --help Show help information. - #| - #|Groups: - #| inputs src - #| - ), - ) -} - -///| -test "render_help tolerates a required env-only option" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("token", long="", env="TOKEN", required=true), - ]) - inspect( - cmd.render_help(), - content=( - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) -} - -///| -test "global set option used in both parent and child argv conflicts" { - let cmd = @argparse.Command( - "demo", - options=[OptionArg("mode", short='m', long="", global=true)], - subcommands=[Command("run")], - ) - try cmd.parse(argv=["-m", "a", "run", "-m", "b"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: argument '-m' cannot be used multiple times - #| - #|Usage: demo [options] [command] - #| - #|Commands: - #| run - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| -m - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "bounded variadic positional stops accepting hyphen tokens once full" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg( - "items", - num_args=ValueRange(lower=0, upper=2), - allow_hyphen_values=true, - ), - ]) - try cmd.parse(argv=["a", "b", "-c"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-c' found - #| - #|Usage: demo [items...] - #| - #|Arguments: - #| items... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -// A leading variadic positional must reserve enough trailing tokens for a later -// positional with a minimum count, so a hyphen token is not mistaken for one of -// its values when the trailing slot still needs filling. -test "hyphen token is rejected while a later positional still needs values" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("head", num_args=ValueRange(lower=0), allow_hyphen_values=true), - PositionArg("tail", num_args=ValueRange(lower=2)), - ]) - try cmd.parse(argv=["-x"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '-x' found - #| - #|Usage: demo [head...] - #| - #|Arguments: - #| head... - #| tail... - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} diff --git a/argparse/argparse_test.mbt b/argparse/argparse_test.mbt deleted file mode 100644 index a590c798f2..0000000000 --- a/argparse/argparse_test.mbt +++ /dev/null @@ -1,778 +0,0 @@ -// 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] { - Map([]) -} - -///| -test "declarative parse basics" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', long="verbose")], - options=[OptionArg("count", long="count", env="COUNT")], - positionals=[PositionArg("name")], - ) - let matches = cmd.parse(argv=["-v", "--count", "3", "alice"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.values is { "count": ["3"], "name": ["alice"], .. }) - assert_true( - matches.sources is { "verbose": Argv, "count": Argv, "name": Argv, .. }, - ) -} - -///| -test "long defaults to name when omitted" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose")], options=[ - OptionArg("count"), - ]) - let matches = cmd.parse(argv=["--verbose", "--count", "3"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.values is { "count": ["3"], .. }) -} - -///| -test "long empty string disables long alias" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", short='v', long="")], - options=[OptionArg("count", short='c', long="")], - ) - - let matches = cmd.parse(argv=["-v", "-c", "3"], env=empty_env()) catch { - _ => panic() - } - assert_true(matches.flags is { "verbose": true, .. }) - assert_true(matches.values is { "count": ["3"], .. }) - - try cmd.parse(argv=["--verbose"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbose' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v - #| -c - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--count", "3"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--count' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| -v - #| -c - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "declaration order controls positional parsing" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first"), - PositionArg("second"), - ]) - - let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) -} - -///| -test "bounded non-last positional remains supported" { - let cmd = @argparse.Command("demo", positionals=[ - PositionArg("first", num_args=ValueRange(lower=1, upper=2)), - PositionArg("second", num_args=@argparse.ValueRange::single()), - ]) - - let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } - assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) - - let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) -} - -///| -test "negatable flag preserves false state" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("cache", long="cache", negatable=true), - ]) - - let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(no_cache.flags is { "cache": false, .. }) - assert_true(no_cache.flag_counts.get("cache") is None) -} - -///| -test "parse failure message contains error and contextual help" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("count", long="count", about="repeat count"), - ]) - - try cmd.parse(argv=["--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--bad' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --count repeat count - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand parse errors include subcommand help" { - let cmd = @argparse.Command("demo", subcommands=[ - Command("echo", options=[OptionArg("times", long="times")]), - ]) - - try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--bad' found - #| - #|Usage: demo echo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --times - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "unknown argument keeps suggestion in final message" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) - - try cmd.parse(argv=["--verbse"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbse' found - #| - #| tip: a similar argument exists: '--verbose' - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "render_help remains available for pure formatting" { - let cmd = @argparse.Command( - "demo", - about="Demo command", - flags=[FlagArg("verbose", short='v', long="verbose")], - options=[OptionArg("count", long="count")], - positionals=[PositionArg("name")], - subcommands=[Command("echo")], - ) - - let help = cmd.render_help() - assert_true(help.contains("Usage: demo [options] [name] [command]")) - assert_true(help.contains("Commands:")) - assert_true(help.contains("Options:")) -} - -///| -test "display help and version" { - let cmd = @argparse.Command("demo", about="demo app", version="1.2.3") - - inspect( - cmd.render_help(), - content=( - #|Usage: demo - #| - #|demo app - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - - try cmd.parse(argv=["--oops"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--oops' found - #| - #|Usage: demo - #| - #|demo app - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "parse error show is readable" { - let cmd = @argparse.Command( - "demo", - flags=[FlagArg("verbose", long="verbose")], - positionals=[PositionArg("name")], - ) - - try cmd.parse(argv=["--verbse"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbse' found - #| - #| tip: a similar argument exists: '--verbose' - #| - #|Usage: demo [options] [name] - #| - #|Arguments: - #| name - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["alice", "bob"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected value 'bob' for '' found; no more were expected - #| - #|Usage: demo [options] [name] - #| - #|Arguments: - #| name - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "relationships and num args" { - let requires_cmd = @argparse.Command("demo", options=[ - OptionArg("mode", long="mode", requires=["config"]), - OptionArg("config", long="config"), - ]) - - try requires_cmd.parse(argv=["--mode", "fast"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'config' (required by 'mode') - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --mode - #| --config - #| - ), - ) - } noraise { - _ => panic() - } - - let appended = @argparse.Command("demo", options=[ - OptionArg("tag", long="tag", action=Append), - ]).parse(argv=["--tag", "a", "--tag", "b", "--tag", "c"], env=empty_env()) catch { - _ => panic() - } - assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) -} - -///| -test "arg groups required and multiple" { - let cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("mode", required=true, multiple=false, args=["fast", "slow"]), - ], - flags=[FlagArg("fast", long="fast"), FlagArg("slow", long="slow")], - ) - - try cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required arguments were not provided: - #| <--fast|--slow> - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --slow - #| - #|Groups: - #| mode [required] [exclusive] --fast, --slow - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--fast", "--slow"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: group conflict mode - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --slow - #| - #|Groups: - #| mode [required] [exclusive] --fast, --slow - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "arg groups requires and conflicts" { - let requires_cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("mode", args=["fast"], requires=["output"]), - ArgGroup("output", args=["json"]), - ], - flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], - ) - - try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required arguments were not provided: - #| <--json> - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --json - #| - #|Groups: - #| mode --fast - #| output --json - #| - ), - ) - } noraise { - _ => panic() - } - - let conflict_cmd = @argparse.Command( - "demo", - groups=[ - ArgGroup("mode", args=["fast"], conflicts_with=["output"]), - ArgGroup("output", args=["json"]), - ], - flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], - ) - - try conflict_cmd.parse(argv=["--fast", "--json"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: group conflict mode conflicts with output - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --fast - #| --json - #| - #|Groups: - #| mode --fast - #| output --json - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "subcommand parsing" { - let echo = @argparse.Command("echo", positionals=[PositionArg("msg")]) - let root = @argparse.Command("root", subcommands=[echo]) - - let matches = root.parse(argv=["echo", "hi"], env=empty_env()) catch { - _ => panic() - } - assert_true( - matches.subcommand is Some(("echo", sub)) && - sub.values is { "msg": ["hi"], .. }, - ) -} - -///| -test "full help snapshot" { - let cmd = @argparse.Command( - "demo", - about="Demo command", - flags=[ - FlagArg("verbose", short='v', long="verbose", about="Enable verbose mode"), - ], - options=[ - OptionArg("count", long="count", about="Repeat count", default_values=[ - "1", - ]), - ], - positionals=[PositionArg("name", about="Target name")], - subcommands=[Command("echo", about="Echo a message")], - ) - inspect( - cmd.render_help(), - content=( - #|Usage: demo [options] [name] [command] - #| - #|Demo command - #| - #|Commands: - #| echo Echo a message - #| help Print help for the subcommand(s). - #| - #|Arguments: - #| name Target name - #| - #|Options: - #| -h, --help Show help information. - #| -v, --verbose Enable verbose mode - #| --count Repeat count [default: 1] - #| - ), - ) -} - -///| -test "value source precedence argv env default" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("level", long="level", env="LEVEL", default_values=["1"]), - ]) - - let from_default = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } - assert_true(from_default.values is { "level": ["1"], .. }) - assert_true(from_default.sources is { "level": Default, .. }) - - let from_env = cmd.parse(argv=[], env={ "LEVEL": "2" }) catch { _ => panic() } - assert_true(from_env.values is { "level": ["2"], .. }) - assert_true(from_env.sources is { "level": Env, .. }) - - let from_argv = cmd.parse(argv=["--level", "3"], env={ "LEVEL": "2" }) catch { - _ => panic() - } - assert_true(from_argv.values is { "level": ["3"], .. }) - assert_true(from_argv.sources is { "level": Argv, .. }) -} - -///| -test "omitted env does not read process environment by default" { - let cmd = @argparse.Command("demo", options=[ - OptionArg("count", long="count", env="COUNT"), - ]) - let matches = cmd.parse(argv=[]) catch { _ => panic() } - assert_true(matches.values is { "count"? : None, .. }) - assert_true(matches.sources is { "count"? : None, .. }) -} - -///| -test "options and multiple values" { - let serve = @argparse.Command("serve") - let cmd = @argparse.Command( - "demo", - options=[ - OptionArg("count", short='c', long="count"), - OptionArg("tag", long="tag", action=Append), - ], - subcommands=[serve], - ) - - let long_count = cmd.parse(argv=["--count", "2"], env=empty_env()) catch { - _ => panic() - } - assert_true(long_count.values is { "count": ["2"], .. }) - - let short_count = cmd.parse(argv=["-c", "3"], env=empty_env()) catch { - _ => panic() - } - assert_true(short_count.values is { "count": ["3"], .. }) - - let multi = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { - _ => panic() - } - assert_true(multi.values is { "tag": ["a", "b"], .. }) - - let subcommand = cmd.parse(argv=["serve"], env=empty_env()) catch { - _ => panic() - } - assert_true(subcommand.subcommand is Some(("serve", _))) -} - -///| -test "negatable and conflicts" { - let cmd = @argparse.Command("demo", flags=[ - FlagArg("cache", long="cache", negatable=true), - FlagArg("failfast", long="failfast", action=SetFalse, negatable=true), - FlagArg("verbose", long="verbose", conflicts_with=["quiet"]), - FlagArg("quiet", long="quiet"), - ]) - - let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { - _ => panic() - } - assert_true(no_cache.flags is { "cache": false, .. }) - assert_true(no_cache.sources is { "cache": Argv, .. }) - - let no_failfast = cmd.parse(argv=["--no-failfast"], env=empty_env()) catch { - _ => panic() - } - assert_true(no_failfast.flags is { "failfast": true, .. }) - - try cmd.parse(argv=["--verbose", "--quiet"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: conflicting arguments: verbose and quiet - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --[no-]cache - #| --[no-]failfast - #| --verbose - #| --quiet - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "flag does not accept inline value" { - let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) - try cmd.parse(argv=["--verbose=true"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--verbose=true' found - #| - #|Usage: demo [options] - #| - #|Options: - #| -h, --help Show help information. - #| --verbose - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "built-in long flags do not accept inline value" { - let cmd = @argparse.Command("demo", version="1.2.3") - - try cmd.parse(argv=["--help=1"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--help=1' found - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } - - try cmd.parse(argv=["--version=1"], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: unexpected argument '--version=1' found - #| - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| -V, --version Show version information. - #| - ), - ) - } noraise { - _ => panic() - } -} - -///| -test "command policies" { - let help_cmd = @argparse.Command("demo", arg_required_else_help=true) - inspect( - help_cmd.render_help(), - content=( - #|Usage: demo - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - - let sub_cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ - Command("echo"), - ]) - inspect( - sub_cmd.render_help(), - content=( - #|Usage: demo - #| - #|Commands: - #| echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - try sub_cmd.parse(argv=[], env=empty_env()) catch { - err => - inspect( - err, - content=( - #|error: the following required argument was not provided: 'subcommand' - #| - #|Usage: demo - #| - #|Commands: - #| echo - #| help Print help for the subcommand(s). - #| - #|Options: - #| -h, --help Show help information. - #| - ), - ) - } noraise { - _ => panic() - } -} diff --git a/argparse/command_test.mbt b/argparse/command_test.mbt new file mode 100644 index 0000000000..8f2744c819 --- /dev/null +++ b/argparse/command_test.mbt @@ -0,0 +1,90 @@ +// 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] { + Map([]) +} + +///| +test "declarative parse basics" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("count", long="count", env="COUNT")], + positionals=[PositionArg("name")], + ) + let matches = cmd.parse(argv=["-v", "--count", "3", "alice"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], "name": ["alice"], .. }) + assert_true( + matches.sources is { "verbose": Argv, "count": Argv, "name": Argv, .. }, + ) +} + +///| +test "command policies" { + let help_cmd = @argparse.Command("demo", arg_required_else_help=true) + inspect( + help_cmd.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + let sub_cmd = @argparse.Command("demo", subcommand_required=true, subcommands=[ + Command("echo"), + ]) + inspect( + sub_cmd.render_help(), + content=( + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + try sub_cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'subcommand' + #| + #|Usage: demo + #| + #|Commands: + #| echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} diff --git a/argparse/errors_test.mbt b/argparse/errors_test.mbt new file mode 100644 index 0000000000..68800b18a7 --- /dev/null +++ b/argparse/errors_test.mbt @@ -0,0 +1,238 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "unknown argument keeps suggestion in final message" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "parse error show is readable" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + positionals=[PositionArg("name")], + ) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] [name] + #| + #|Arguments: + #| name + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["alice", "bob"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'bob' for '' found; no more were expected + #| + #|Usage: demo [options] [name] + #| + #|Arguments: + #| name + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "unknown argument suggestions are exposed" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", short='v', long="verbose"), + ]) + + try cmd.parse(argv=["--verbse"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbse' found + #| + #| tip: a similar argument exists: '--verbose' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-x"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-x' found + #| + #| tip: a similar argument exists: '-v' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--zzzzzzzzzz"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--zzzzzzzzzz' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "unified error message formatting remains stable" { + let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--tag"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "unknown short suggestion can be absent" { + let cmd = @argparse.Command("demo", disable_help_flag=true, options=[ + OptionArg("name", long="name"), + ]) + + try cmd.parse(argv=["-x"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-x' found + #| + #|Usage: demo [options] + #| + #|Options: + #| --name + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "Debug for argparse enums" { + @debug.debug_inspect(@argparse.FlagAction::SetTrue, content="SetTrue") + @debug.debug_inspect(@argparse.FlagAction::Count, content="Count") + @debug.debug_inspect(@argparse.OptionAction::Set, content="Set") + @debug.debug_inspect(@argparse.OptionAction::Append, content="Append") +} diff --git a/argparse/globals_test.mbt b/argparse/globals_test.mbt new file mode 100644 index 0000000000..022af723e1 --- /dev/null +++ b/argparse/globals_test.mbt @@ -0,0 +1,609 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "global option merges parent and child values" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + short='p', + long="profile", + action=Append, + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse( + argv=["--profile", "parent", "run", "--profile", "child"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent", "child"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent", "child"], .. }, + ) +} + +///| +test "global requires is validated after parent-child merge" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", requires=["config"], global=true), + OptionArg("config", long="config", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse( + argv=["--config", "a.toml", "run", "--mode", "fast"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true( + parsed.values is { "config": ["a.toml"], "mode": ["fast"], .. } && + parsed.subcommand is Some(("run", sub)) && + sub.values is { "config": ["a.toml"], "mode": ["fast"], .. }, + ) +} + +///| +test "global append keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + long="profile", + action=Append, + env="PROFILE", + default_values=["def"], + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ + "PROFILE": "env", + }) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": Argv, .. }, + ) +} + +///| +test "global scalar keeps parent argv over child env/default" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "profile", + long="profile", + env="PROFILE", + default_values=["def"], + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--profile", "parent", "run"], env={ + "PROFILE": "env", + }) catch { + _ => panic() + } + assert_true(matches.values is { "profile": ["parent"], .. }) + assert_true(matches.sources is { "profile": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.values is { "profile": ["parent"], .. } && + sub.sources is { "profile": Argv, .. }, + ) +} + +///| +test "global count merges parent and child occurrences" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["-v", "run", "-v", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flag_counts is { "verbose": 3, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 3, .. }, + ) +} + +///| +test "global count keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + action=Count, + env="VERBOSE", + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["-v", "run"], env={ "VERBOSE": "1" }) catch { + _ => panic() + } + assert_true(matches.flag_counts is { "verbose": 1, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global flag keeps parent argv over child env fallback" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", env="VERBOSE", global=true)], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["--verbose", "run"], env={ "VERBOSE": "0" }) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global count source keeps env across subcommand merge" { + let child = @argparse.Command("run") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + action=Count, + env="VERBOSE", + global=true, + ), + ], + subcommands=[child], + ) + + let matches = cmd.parse(argv=["run"], env={ "VERBOSE": "1" }) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.flag_counts is { "verbose": 1, .. }) + assert_true(matches.sources is { "verbose": Env, .. }) + assert_true( + matches.subcommand is Some(("run", sub)) && + sub.flag_counts is { "verbose": 1, .. } && + sub.sources is { "verbose": Env, .. }, + ) +} + +///| +test "global value from child default is merged back to parent" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", default_values=["safe"], global=true), + OptionArg("unused", long="unused", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.values is { "mode": ["safe"], "unused"? : None, .. }) + assert_true(parsed.sources is { "mode": Default, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["safe"], .. } && + sub.sources is { "mode": Default, .. }, + ) +} + +///| +test "env-only global is propagated to nested subcommand matches" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("level", long="", env="LEVEL", global=true)], + subcommands=[Command("run", subcommands=[Command("leaf")])], + ) + + let parsed = cmd.parse(argv=["run", "leaf"], env={ "LEVEL": "5" }) catch { + _ => panic() + } + assert_true(parsed.values is { "level": ["5"], .. }) + assert_true(parsed.sources is { "level": Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub_run)) && + sub_run.values is { "level": ["5"], .. } && + sub_run.sources is { "level": Env, .. } && + sub_run.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.values is { "level": ["5"], .. } && + sub_leaf.sources is { "level": Env, .. }, + ) +} + +///| +test "child global arg with inherited global name updates parent global" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("mode", long="mode", default_values=["safe"], global=true), + ], + subcommands=[ + Command("run", options=[OptionArg("mode", long="mode", global=true)]), + ], + ) + + let parsed = cmd.parse(argv=["run", "--mode", "fast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.sources is { "mode": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Argv, .. }, + ) +} + +///| +test "child global override env/default win over inherited definition" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg( + "mode", + long="mode", + env="ROOT_MODE", + default_values=["safe"], + global=true, + ), + ], + subcommands=[ + Command("run", options=[ + OptionArg( + "mode", + long="mode", + env="RUN_MODE", + default_values=["fast"], + global=true, + ), + ]), + ], + ) + + let from_env = cmd.parse(argv=["run"], env={ + "ROOT_MODE": "root-env", + "RUN_MODE": "run-env", + }) catch { + _ => panic() + } + assert_true(from_env.values is { "mode": ["run-env"], .. }) + assert_true(from_env.sources is { "mode": Env, .. }) + assert_true( + from_env.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["run-env"], .. } && + sub.sources is { "mode": Env, .. }, + ) + + let from_default = cmd.parse(argv=["run"], env=Map([])) catch { _ => panic() } + assert_true(from_default.values is { "mode": ["fast"], .. }) + assert_true(from_default.sources is { "mode": Default, .. }) + assert_true( + from_default.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Default, .. }, + ) +} + +///| +test "inherited argv global satisfies child required global override" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[ + Command("run", options=[ + OptionArg("mode", long="mode", required=true, global=true), + ]), + ], + ) + + let parsed = cmd.parse(argv=["--mode", "fast", "run"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.sources is { "mode": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "mode": ["fast"], .. } && + sub.sources is { "mode": Argv, .. }, + ) +} + +///| +test "global append env value from child is merged back to parent" { + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("tag", long="tag", action=Append, env="TAG", global=true), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run"], env={ "TAG": "env-tag" }) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["env-tag"], .. }) + assert_true(parsed.sources is { "tag": Env, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.values is { "tag": ["env-tag"], .. } && + sub.sources is { "tag": Env, .. }, + ) +} + +///| +test "global flag set in child argv is merged back to parent" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse(argv=["run", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": true, .. }) + assert_true(parsed.sources is { "verbose": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": true, .. } && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global count negation after subcommand resets merged state" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + long="verbose", + action=Count, + negatable=true, + global=true, + ), + ], + subcommands=[Command("run")], + ) + + let parsed = cmd.parse( + argv=["--verbose", "run", "--no-verbose"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": false, .. }) + assert_true(parsed.flag_counts.get("verbose") is None) + assert_true(parsed.sources is { "verbose": Argv, .. }) + assert_true( + parsed.subcommand is Some(("run", sub)) && + sub.flags is { "verbose": false, .. } && + sub.flag_counts.get("verbose") is None && + sub.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "global set option rejects duplicate occurrences across subcommands" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[Command("run")], + ) + try + cmd.parse(argv=["--mode", "a", "run", "--mode", "b"], env=empty_env()) + catch { + err => + inspect( + err, + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "nested subcommands inherit finalized globals from ancestors" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command("mid", subcommands=[leaf]) + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose", global=true)], + subcommands=[mid], + ) + + let parsed = cmd.parse(argv=["--verbose", "mid", "leaf"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "verbose": true, .. }) + assert_true( + parsed.subcommand is Some(("mid", mid_matches)) && + mid_matches.flags is { "verbose": true, .. } && + mid_matches.subcommand is Some(("leaf", leaf_matches)) && + leaf_matches.flags is { "verbose": true, .. } && + leaf_matches.sources is { "verbose": Argv, .. }, + ) +} + +///| +test "nested global override deduplicates count merge by name" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command( + "mid", + flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], + subcommands=[leaf], + ) + let root = @argparse.Command( + "root", + flags=[FlagArg("verbose", long="verbose", action=Count, global=true)], + subcommands=[mid], + ) + + let parsed = root.parse(argv=["mid", "leaf", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flag_counts is { "verbose": 1, .. }) + assert_true( + parsed.subcommand is Some(("mid", sub_mid)) && + sub_mid.flag_counts is { "verbose": 1, .. } && + sub_mid.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.flag_counts is { "verbose": 1, .. }, + ) +} + +///| +test "nested global override keeps single set value without false duplicate error" { + let leaf = @argparse.Command("leaf") + let mid = @argparse.Command( + "mid", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[leaf], + ) + let root = @argparse.Command( + "root", + options=[OptionArg("mode", long="mode", global=true)], + subcommands=[mid], + ) + + let parsed = root.parse( + argv=["mid", "leaf", "--mode", "fast"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true( + parsed.subcommand is Some(("mid", sub_mid)) && + sub_mid.values is { "mode": ["fast"], .. } && + sub_mid.subcommand is Some(("leaf", sub_leaf)) && + sub_leaf.values is { "mode": ["fast"], .. }, + ) +} + +///| +test "compatible global flag can be redeclared in a subcommand" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("v", short='v', long="", global=true)], + subcommands=[ + Command("child", flags=[FlagArg("v", short='v', long="", global=true)]), + ], + ) + let parsed = cmd.parse(argv=["-v", "child"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "v": true, .. }) + assert_true( + parsed.subcommand is Some(("child", sub)) && sub.flags is { "v": true, .. }, + ) +} + +///| +test "global set option used in both parent and child argv conflicts" { + let cmd = @argparse.Command( + "demo", + options=[OptionArg("mode", short='m', long="", global=true)], + subcommands=[Command("run")], + ) + try cmd.parse(argv=["-m", "a", "run", "-m", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: argument '-m' cannot be used multiple times + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| -m + #| + ), + ) + } noraise { + _ => panic() + } +} diff --git a/argparse/help_test.mbt b/argparse/help_test.mbt new file mode 100644 index 0000000000..026f7cbc95 --- /dev/null +++ b/argparse/help_test.mbt @@ -0,0 +1,962 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "parse failure message contains error and contextual help" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", long="count", about="repeat count"), + ]) + + try cmd.parse(argv=["--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --count repeat count + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand parse errors include subcommand help" { + let cmd = @argparse.Command("demo", subcommands=[ + Command("echo", options=[OptionArg("times", long="times")]), + ]) + + try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo echo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --times + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "render_help remains available for pure formatting" { + let cmd = @argparse.Command( + "demo", + about="Demo command", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("count", long="count")], + positionals=[PositionArg("name")], + subcommands=[Command("echo")], + ) + + let help = cmd.render_help() + assert_true(help.contains("Usage: demo [options] [name] [command]")) + assert_true(help.contains("Commands:")) + assert_true(help.contains("Options:")) +} + +///| +test "display help and version" { + let cmd = @argparse.Command("demo", about="demo app", version="1.2.3") + + inspect( + cmd.render_help(), + content=( + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo + #| + #|demo app + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "full help snapshot" { + let cmd = @argparse.Command( + "demo", + about="Demo command", + flags=[ + FlagArg("verbose", short='v', long="verbose", about="Enable verbose mode"), + ], + options=[ + OptionArg("count", long="count", about="Repeat count", default_values=[ + "1", + ]), + ], + positionals=[PositionArg("name", about="Target name")], + subcommands=[Command("echo", about="Echo a message")], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] [name] [command] + #| + #|Demo command + #| + #|Commands: + #| echo Echo a message + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| name Target name + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose Enable verbose mode + #| --count Repeat count [default: 1] + #| + ), + ) +} + +///| +test "render help snapshot with groups and hidden entries" { + let cmd = @argparse.Command( + "render", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=[ + "fast", "slow", "path", + ]), + ], + subcommands=[ + Command("run", about="run"), + Command("hidden", about="hidden", hidden=true), + ], + flags=[ + FlagArg("fast", short='f', long="fast"), + FlagArg("slow", long="slow", hidden=true), + FlagArg("cache", long="cache", negatable=true, about="cache"), + ], + options=[ + OptionArg( + "path", + short='p', + long="path", + env="PATH_ENV", + default_values=["a", "b"], + required=true, + ), + ], + positionals=[ + PositionArg("target", num_args=@argparse.ValueRange::single()), + PositionArg("rest", num_args=ValueRange(lower=0)), + PositionArg("secret", hidden=true), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: render --path [options] [rest...] [command] + #| + #|Commands: + #| run run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| target + #| rest... + #| + #|Options: + #| -h, --help Show help information. + #| -f, --fast + #| --[no-]cache cache + #| -p, --path [env: PATH_ENV] [default: a, b] + #| + #|Groups: + #| mode [required] [exclusive] -f, --fast, -p, --path + #| + ), + ) +} + +///| +test "render help conversion coverage snapshot" { + let cmd = @argparse.Command( + "shape", + groups=[ArgGroup("grp", args=["f", "opt", "pos"])], + flags=[ + FlagArg( + "f", + short='f', + about="f", + env="F_ENV", + requires=["opt"], + global=true, + hidden=true, + ), + ], + options=[ + OptionArg( + "opt", + short='o', + about="opt", + default_values=["x", "y"], + env="OPT_ENV", + allow_hyphen_values=true, + required=true, + global=true, + hidden=true, + conflicts_with=["pos"], + ), + ], + positionals=[ + PositionArg( + "pos", + about="pos", + env="POS_ENV", + default_values=["p1", "p2"], + num_args=ValueRange(lower=0, upper=2), + allow_hyphen_values=true, + requires=["opt"], + conflicts_with=["f"], + global=true, + hidden=true, + ), + ], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: shape + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "default subcommand help annotation and child error context" { + let cmd = @argparse.Command( + "openseek", + options=[OptionArg("config", long="config", about="config", global=true)], + subcommands=[ + Command("tui", about="Start interactive UI", options=[ + OptionArg("theme", long="theme", about="theme"), + ]), + Command("mcp", about="Run MCP server"), + ], + default_subcommand="tui", + ) + + inspect( + cmd.render_help(), + content=( + #|Usage: openseek [options] [command] + #| + #|Commands: + #| tui Start interactive UI (default) + #| mcp Run MCP server + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| --config config + #| + ), + ) + + try cmd.parse(argv=["--unknown"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--unknown' found + #| + #|Usage: openseek tui [options] + #| + #|Start interactive UI + #| + #|Options: + #| -h, --help Show help information. + #| --config config + #| --theme theme + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "help subcommand styles and errors" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command("demo", subcommands=[leaf]) + + inspect( + leaf.render_help(), + content=( + #|Usage: echo + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + + try cmd.parse(argv=["help", "--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected help argument: --bad + #| + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "missing"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unknown subcommand: missing + #| + #|Usage: demo [command] + #| + #|Commands: + #| echo echo + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand help includes inherited global options" { + let leaf = @argparse.Command("echo", about="echo") + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg( + "verbose", + short='v', + long="verbose", + about="Enable verbose mode", + global=true, + ), + ], + subcommands=[leaf], + ) + + try cmd.parse(argv=["echo", "--bad"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--bad' found + #| + #|Usage: demo echo [options] + #| + #|echo + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose Enable verbose mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "help subcommand suggestions exclude hidden commands" { + let cmd = @argparse.Command("demo", subcommands=[ + Command("serve", about="serve"), + Command("secret", about="secret", hidden=true), + ]) + try cmd.parse(argv=["help", "secrt"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unknown subcommand: secrt + #| + #|Usage: demo [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "builtin and custom help/version dispatch edge paths" { + let custom_help = @argparse.Command("demo", flags=[ + FlagArg("custom_help", short='h', long="help", about="custom help"), + ]) + let help_short = custom_help.parse(argv=["-h"], env=empty_env()) catch { + _ => panic() + } + let help_long = custom_help.parse(argv=["--help"], env=empty_env()) catch { + _ => panic() + } + assert_true(help_short.flags is { "custom_help": true, .. }) + assert_true(help_long.flags is { "custom_help": true, .. }) + inspect( + custom_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help custom help + #| + ), + ) + + let custom_version = @argparse.Command("demo", version="1.0", flags=[ + FlagArg("custom_version", short='V', long="version", about="custom version"), + ]) + let version_short = custom_version.parse(argv=["-V"], env=empty_env()) catch { + _ => panic() + } + let version_long = custom_version.parse(argv=["--version"], env=empty_env()) catch { + _ => panic() + } + assert_true(version_short.flags is { "custom_version": true, .. }) + assert_true(version_long.flags is { "custom_version": true, .. }) + inspect( + custom_version.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version custom version + #| + ), + ) + + let versioned = @argparse.Command("demo", version="1.2.3") + inspect( + versioned.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + + try versioned.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } + + let long_help = @argparse.Command("demo", flags=[ + FlagArg("assist", long="assist", action=Help), + ]) + inspect( + long_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --assist + #| + ), + ) + + let short_help = @argparse.Command("demo", flags=[ + FlagArg("assist", short='?', action=Help), + ]) + inspect( + short_help.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -?, --assist + #| + ), + ) +} + +///| +test "help rendering edge paths stay stable" { + let required_many = @argparse.Command("demo", positionals=[ + PositionArg("files", num_args=ValueRange(lower=1)), + ]) + let required_help = required_many.render_help() + assert_true(required_help.has_prefix("Usage: demo ")) + + let short_only_builtin = @argparse.Command("demo", options=[ + OptionArg("helpopt", long="help"), + ]) + let short_only_text = short_only_builtin.render_help() + assert_true(short_only_text.has_prefix("Usage: demo")) + try short_only_builtin.parse(argv=["--help"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--help' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h Show help information. + #| --help + #| + ), + ) + } noraise { + _ => panic() + } + + let long_only_builtin = @argparse.Command("demo", flags=[ + FlagArg("custom_h", short='h'), + ]) + let long_only_text = long_only_builtin.render_help() + assert_true(long_only_text.has_prefix("Usage: demo")) + let custom_h = long_only_builtin.parse(argv=["-h"], env=empty_env()) catch { + _ => panic() + } + assert_true(custom_h.flags is { "custom_h": true, .. }) + + let empty_options = @argparse.Command( + "demo", + disable_help_flag=true, + disable_version_flag=true, + ) + let empty_options_help = empty_options.render_help() + assert_true(empty_options_help.has_prefix("Usage: demo")) + + let implicit_group = @argparse.Command("demo", positionals=[ + PositionArg("item"), + ]) + let implicit_group_help = implicit_group.render_help() + assert_true(implicit_group_help.has_prefix("Usage: demo [item]")) + + let sub_visible = @argparse.Command("demo", disable_help_subcommand=true, subcommands=[ + Command("run"), + ]) + let sub_help = sub_visible.render_help() + assert_true(sub_help.has_prefix("Usage: demo [command]")) +} + +///| +test "version action dispatches on custom long and short flags" { + let cmd = @argparse.Command("demo", version="2.0.0", flags=[ + FlagArg("show_long", long="show-version", action=Version), + FlagArg("show_short", short='S', action=Version), + ]) + + inspect( + cmd.render_help(), + content=( + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| --show-version + #| -S, --show_short + #| + ), + ) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| --show-version + #| -S, --show_short + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "global version action keeps parent version text in subcommand context" { + let cmd = @argparse.Command( + "demo", + version="1.0.0", + flags=[ + FlagArg( + "show_version", + short='S', + long="show-version", + action=Version, + global=true, + ), + ], + subcommands=[Command("run")], + ) + + try cmd.parse(argv=["--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo [options] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| -S, --show-version + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run [options] + #| + #|Options: + #| -h, --help Show help information. + #| -S, --show-version + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand help puts required options in usage" { + let cmd = @argparse.Command("demo", subcommands=[ + Command( + "run", + about="Run a file", + options=[OptionArg("mode", short='m', required=true)], + positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())], + ), + ]) + + try cmd.parse(argv=["run", "--oops"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--oops' found + #| + #|Usage: demo run --mode + #| + #|Run a file + #| + #|Arguments: + #| file + #| + #|Options: + #| -h, --help Show help information. + #| -m, --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "required_option_usage covers option/flag/hidden/short-only" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg("verbose", short='v', long="verbose", required=true), + FlagArg("secret", short='s', long="secret", required=true, hidden=true), + ], + options=[ + OptionArg("mode", long="mode", required=true), + OptionArg("tag", short='t', long="", required=true), + OptionArg("optional", long="optional"), + ], + positionals=[ + PositionArg("required_pos", num_args=@argparse.ValueRange::single()), + ], + ) + let help = cmd.render_help() + assert_true( + help.has_prefix( + "Usage: demo --verbose --mode -t [options] ", + ), + ) +} + +///| +test "required_option_usage returns empty when nothing required" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="verbose")], + options=[OptionArg("mode", long="mode")], + ) + let help = cmd.render_help() + assert_true(help.has_prefix("Usage: demo [options]")) +} + +///| +test "positional range 0..1 renders as single optional value" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=1)), + ]) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [x] + #| + #|Arguments: + #| x + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} + +///| +test "required group usage lists every member shape" { + let cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("g", required=true, args=["opt", "posm", "poss", "sf", "ef"]), + ], + flags=[FlagArg("sf", short='s', long=""), FlagArg("ef", long="", env="EF")], + options=[OptionArg("opt", long="opt")], + positionals=[ + PositionArg("posm", num_args=ValueRange(lower=0)), + PositionArg("poss", num_args=ValueRange(lower=0, upper=1)), + ], + ) + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required arguments were not provided: + #| <--opt |||-s|ef> + #| + #|Usage: demo [options] [posm...] [poss] + #| + #|Arguments: + #| posm... + #| poss + #| + #|Options: + #| -h, --help Show help information. + #| -s + #| --opt + #| + #|Groups: + #| g [required] -s, ef, --opt , posm, poss + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "help subcommand cannot follow positional arguments" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + try cmd.parse(argv=["raw", "help"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: subcommand 'help' cannot be used with positional arguments + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "render_help lists positional members inside argument groups" { + let cmd = @argparse.Command( + "demo", + groups=[ArgGroup("inputs", args=["src"])], + positionals=[PositionArg("src")], + ) + inspect( + cmd.render_help(), + content=( + #|Usage: demo [src] + #| + #|Arguments: + #| src + #| + #|Options: + #| -h, --help Show help information. + #| + #|Groups: + #| inputs src + #| + ), + ) +} + +///| +test "render_help tolerates a required env-only option" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("token", long="", env="TOKEN", required=true), + ]) + inspect( + cmd.render_help(), + content=( + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) +} diff --git a/argparse/options_test.mbt b/argparse/options_test.mbt new file mode 100644 index 0000000000..73799f013d --- /dev/null +++ b/argparse/options_test.mbt @@ -0,0 +1,1043 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "long defaults to name when omitted" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose")], options=[ + OptionArg("count"), + ]) + let matches = cmd.parse(argv=["--verbose", "--count", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], .. }) +} + +///| +test "negatable flag preserves false state" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("cache", long="cache", negatable=true), + ]) + + let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_cache.flags is { "cache": false, .. }) + assert_true(no_cache.flag_counts.get("cache") is None) +} + +///| +test "value source precedence argv env default" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("level", long="level", env="LEVEL", default_values=["1"]), + ]) + + let from_default = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } + assert_true(from_default.values is { "level": ["1"], .. }) + assert_true(from_default.sources is { "level": Default, .. }) + + let from_env = cmd.parse(argv=[], env={ "LEVEL": "2" }) catch { _ => panic() } + assert_true(from_env.values is { "level": ["2"], .. }) + assert_true(from_env.sources is { "level": Env, .. }) + + let from_argv = cmd.parse(argv=["--level", "3"], env={ "LEVEL": "2" }) catch { + _ => panic() + } + assert_true(from_argv.values is { "level": ["3"], .. }) + assert_true(from_argv.sources is { "level": Argv, .. }) +} + +///| +test "omitted env does not read process environment by default" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", long="count", env="COUNT"), + ]) + let matches = cmd.parse(argv=[]) catch { _ => panic() } + assert_true(matches.values is { "count"? : None, .. }) + assert_true(matches.sources is { "count"? : None, .. }) +} + +///| +test "options and multiple values" { + let serve = @argparse.Command("serve") + let cmd = @argparse.Command( + "demo", + options=[ + OptionArg("count", short='c', long="count"), + OptionArg("tag", long="tag", action=Append), + ], + subcommands=[serve], + ) + + let long_count = cmd.parse(argv=["--count", "2"], env=empty_env()) catch { + _ => panic() + } + assert_true(long_count.values is { "count": ["2"], .. }) + + let short_count = cmd.parse(argv=["-c", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_count.values is { "count": ["3"], .. }) + + let multi = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(multi.values is { "tag": ["a", "b"], .. }) + + let subcommand = cmd.parse(argv=["serve"], env=empty_env()) catch { + _ => panic() + } + assert_true(subcommand.subcommand is Some(("serve", _))) +} + +///| +test "flag does not accept inline value" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + try cmd.parse(argv=["--verbose=true"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbose=true' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "built-in long flags do not accept inline value" { + let cmd = @argparse.Command("demo", version="1.2.3") + + try cmd.parse(argv=["--help=1"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--help=1' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--version=1"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--version=1' found + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| -V, --version Show version information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "count flags and sources with pattern matching" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", short='v', long="verbose", action=Count), + ]) + let matches = cmd.parse(argv=["-v", "-v", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.flag_counts is { "verbose": 3, .. }) + assert_true(matches.sources is { "verbose": Argv, .. }) +} + +///| +test "append option action is publicly selectable" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]) + let appended = cmd.parse(argv=["--tag", "a", "--tag", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b"], .. }) + assert_true(appended.sources is { "tag": Argv, .. }) +} + +///| +test "negation parsing and invalid negation forms" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("cache", long="cache", negatable=true)], + options=[OptionArg("path", long="path")], + ) + + let off = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(off.flags is { "cache": false, .. }) + assert_true(off.sources is { "cache": Argv, .. }) + + try cmd.parse(argv=["--no-path"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--no-path' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--no-missing"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--no-missing' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--no-cache=1"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--no-cache=1' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --path + #| + ), + ) + } noraise { + _ => panic() + } + + let count_cmd = @argparse.Command("demo", flags=[ + FlagArg("verbose", long="verbose", action=Count, negatable=true), + ]) + let reset = count_cmd.parse( + argv=["--verbose", "--no-verbose"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(reset.flags is { "verbose": false, .. }) + assert_true(reset.flag_counts is { "verbose"? : None, .. }) + assert_true(reset.sources is { "verbose": Argv, .. }) +} + +///| +test "env parsing for settrue setfalse count and invalid values" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("on", long="on", action=SetTrue, env="ON"), + FlagArg("off", long="off", action=SetFalse, env="OFF"), + FlagArg("v", long="v", action=Count, env="V"), + ]) + + let parsed = cmd.parse(argv=[], env={ "ON": "true", "OFF": "true", "V": "3" }) catch { + _ => panic() + } + assert_true(parsed.flags is { "on": true, "off": false, "v": true, .. }) + assert_true(parsed.flag_counts is { "v": 3, .. }) + assert_true(parsed.sources is { "on": Env, "off": Env, "v": Env, .. }) + + try cmd.parse(argv=[], env={ "ON": "bad" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "OFF": "bad" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value 'bad' for boolean flag; expected one of: 1, 0, true, false, yes, no, on, off + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "V": "bad" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value 'bad' for count; expected a non-negative integer + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=[], env={ "V": "-1" }) catch { + err => + inspect( + err, + content=( + #|error: invalid value '-1' for count; expected a non-negative integer + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --on [env: ON] + #| --off [env: OFF] + #| --v [env: V] + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "options consume exactly one value per occurrence" { + let cmd = @argparse.Command("demo", options=[OptionArg("tag", long="tag")]) + let parsed = cmd.parse(argv=["--tag", "a"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tag": ["a"], .. }) + assert_true(parsed.sources is { "tag": Argv, .. }) + + try cmd.parse(argv=["--tag", "a", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'b' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "set options reject duplicate occurrences" { + let cmd = @argparse.Command("demo", options=[OptionArg("mode", long="mode")]) + try cmd.parse(argv=["--mode", "a", "--mode", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "append options collect values across repeated occurrences" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("arg", long="arg", action=Append), + ]) + let parsed = cmd.parse(argv=["--arg", "x", "--arg", "y"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "arg": ["x", "y"], .. }) + assert_true(parsed.sources is { "arg": Argv, .. }) +} + +///| +test "option parsing stops at the next option token" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("arg", short='a', long="arg")], + ) + + let stopped = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(stopped.values is { "arg": ["x"], .. }) + assert_true(stopped.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["--arg=x", "y", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'y' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| -a, --arg + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-ax", "y", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'y' found; no more were expected + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| -a, --arg + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "options always require a value" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("opt", long="opt")], + ) + try cmd.parse(argv=["--opt", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--opt' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --opt + #| + ), + ) + } noraise { + _ => panic() + } + + let zero_value_required = @argparse.Command("demo", options=[ + OptionArg("opt", long="opt", required=true), + ]).parse(argv=["--opt", "x"], env=empty_env()) catch { + _ => panic() + } + assert_true(zero_value_required.values is { "opt": ["x"], .. }) +} + +///| +test "default argv path is reachable" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("rest", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let _ = cmd.parse(env=empty_env()) catch { _ => panic() } +} + +///| +test "options require one value per occurrence" { + let with_value = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag"), + ]).parse(argv=["--tag", "x"], env=empty_env()) catch { + _ => panic() + } + assert_true(with_value.values is { "tag": ["x"], .. }) + + try + @argparse.Command("demo", options=[OptionArg("tag", long="tag")]).parse( + argv=["--tag"], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "short options require one value before next option token" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", short='v')], options=[ + OptionArg("x", short='x'), + ]) + let ok = cmd.parse(argv=["-x", "a", "-v"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "x": ["a"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["-x", "-v"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '-x' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v, --verbose + #| -x, --x + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "single-value options avoid consuming additional option values" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("one", long="one")], + ) + + let parsed = cmd.parse(argv=["--one", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "one": ["x"], .. }) + assert_true(parsed.flags is { "verbose": true, .. }) +} + +///| +test "missing option values are reported when next token is another option" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", long="verbose")], + options=[OptionArg("arg", long="arg")], + ) + + let ok = cmd.parse(argv=["--arg", "x", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(ok.values is { "arg": ["x"], .. }) + assert_true(ok.flags is { "verbose": true, .. }) + + try cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--arg' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| --arg + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "short-only set options use short label in duplicate errors" { + let cmd = @argparse.Command("demo", options=[OptionArg("mode", short='m')]) + try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: argument '--mode' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -m, --mode + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "setfalse flags apply false when present" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("failfast", long="failfast", action=SetFalse), + ]) + let parsed = cmd.parse(argv=["--failfast"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "failfast": false, .. }) + assert_true(parsed.sources is { "failfast": Argv, .. }) +} + +///| +test "non-bmp short option token does not panic" { + let cmd = @argparse.Command("demo", flags=[FlagArg("party", short='🎉')]) + let parsed = cmd.parse(argv=["-🎉"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.flags is { "party": true, .. }) +} + +///| +test "option env values remain string values instead of flags" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", env="MODE"), + ]) + let parsed = cmd.parse(argv=[], env={ "MODE": "fast" }) catch { _ => panic() } + assert_true(parsed.values is { "mode": ["fast"], .. }) + assert_true(parsed.flags.get("mode") is None) + assert_true(parsed.sources is { "mode": Env, .. }) +} + +///| +test "duplicate short-only set option reports the short flag label" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("mode", short='m', long=""), + ]) + try cmd.parse(argv=["-m", "a", "-m", "b"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: argument '-m' cannot be used multiple times + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -m + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "boolean env flags accept falsy values" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("on", long="on", action=SetTrue, env="ON"), + FlagArg("off", long="off", action=SetFalse, env="OFF"), + ]) + let parsed = cmd.parse(argv=[], env={ "ON": "0", "OFF": "no" }) catch { + _ => panic() + } + assert_true(parsed.flags is { "on": false, "off": true, .. }) + assert_true(parsed.sources is { "on": Env, "off": Env, .. }) +} + +///| +test "unknown long flag with an empty name has no suggestion" { + let cmd = @argparse.Command("demo", flags=[FlagArg("verbose", long="verbose")]) + try cmd.parse(argv=["--=value"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --verbose + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "long empty string disables long alias" { + let cmd = @argparse.Command( + "demo", + flags=[FlagArg("verbose", short='v', long="")], + options=[OptionArg("count", short='c', long="")], + ) + + let matches = cmd.parse(argv=["-v", "-c", "3"], env=empty_env()) catch { + _ => panic() + } + assert_true(matches.flags is { "verbose": true, .. }) + assert_true(matches.values is { "count": ["3"], .. }) + + try cmd.parse(argv=["--verbose"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--verbose' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v + #| -c + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--count", "3"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '--count' found + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -v + #| -c + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "long and short value parsing branches" { + let cmd = @argparse.Command("demo", options=[ + OptionArg("count", short='c', long="count"), + ]) + + let long_inline = cmd.parse(argv=["--count=2"], env=empty_env()) catch { + _ => panic() + } + assert_true(long_inline.values is { "count": ["2"], .. }) + + let short_inline = cmd.parse(argv=["-c=3"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_inline.values is { "count": ["3"], .. }) + + let short_attached = cmd.parse(argv=["-c4"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_attached.values is { "count": ["4"], .. }) + + try cmd.parse(argv=["--count"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--count' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["-c"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '-c' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| -c, --count + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "option values reject hyphen tokens unless allow_hyphen_values is enabled" { + let strict = @argparse.Command("demo", options=[ + OptionArg("pattern", long="pattern"), + ]) + let mut rejected = false + try strict.parse(argv=["--pattern", "-file"], env=empty_env()) catch { + err => { + inspect( + err, + content=( + #|error: a value is required for '--pattern' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --pattern + #| + ), + ) + rejected = true + } + } noraise { + _ => rejected = true + } + assert_true(rejected) + + let permissive = @argparse.Command("demo", options=[ + OptionArg("pattern", long="pattern", allow_hyphen_values=true), + ]) + let parsed = permissive.parse(argv=["--pattern", "-file"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "pattern": ["-file"], .. }) + assert_true(parsed.sources is { "pattern": Argv, .. }) +} + +///| +test "options with allow_hyphen_values accept option-like single values" { + let cmd = @argparse.Command( + "demo", + flags=[ + FlagArg("verbose", long="verbose"), + FlagArg("cache", long="cache", negatable=true), + FlagArg("quiet", short='q'), + ], + options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], + ) + + let known_long = cmd.parse(argv=["--arg", "--verbose"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_long.values is { "arg": ["--verbose"], .. }) + assert_true(known_long.flags is { "verbose"? : None, .. }) + + let negated = cmd.parse(argv=["--arg", "--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(negated.values is { "arg": ["--no-cache"], .. }) + assert_true(negated.flags is { "cache"? : None, .. }) + + let unknown_long_value = cmd.parse( + argv=["--arg", "--mystery"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(unknown_long_value.values is { "arg": ["--mystery"], .. }) + + let known_short = cmd.parse(argv=["--arg", "-q"], env=empty_env()) catch { + _ => panic() + } + assert_true(known_short.values is { "arg": ["-q"], .. }) + assert_true(known_short.flags is { "quiet"? : None, .. }) + + let cmd_with_rest = @argparse.Command( + "demo", + options=[OptionArg("arg", long="arg", allow_hyphen_values=true)], + positionals=[ + PositionArg( + "rest", + num_args=ValueRange(lower=0), + allow_hyphen_values=true, + ), + ], + ) + let sentinel_stop = cmd_with_rest.parse( + argv=["--arg", "x", "--", "tail"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(sentinel_stop.values is { "arg": ["x"], "rest": ["tail"], .. }) +} + +///| +test "defaults and value range helpers through public API" { + let defaults = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", action=Append, default_values=["a", "b"]), + OptionArg("one", long="one", default_values=["x"]), + ]) + let by_default = defaults.parse(argv=[], env=empty_env()) catch { + _ => panic() + } + assert_true(by_default.values is { "mode": ["a", "b"], "one": ["x"], .. }) + assert_true(by_default.sources is { "mode": Default, "one": Default, .. }) + + let upper_only = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]) + let upper_parsed = upper_only.parse( + argv=["--tag", "a", "--tag", "b", "--tag", "c"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(upper_parsed.values is { "tag": ["a", "b", "c"], .. }) + + let lower_only = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag"), + ]) + let lower_absent = lower_only.parse(argv=[], env=empty_env()) catch { + _ => panic() + } + assert_true(lower_absent.values is { "tag"? : None, .. }) + + try lower_only.parse(argv=["--tag"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: a value is required for '--tag' but none was supplied + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --tag + #| + ), + ) + } noraise { + _ => panic() + } + + let single_range = @argparse.ValueRange::single() + inspect( + single_range, + content=( + #|{lower: 1, upper: Some(1)} + ), + ) +} diff --git a/argparse/positionals_test.mbt b/argparse/positionals_test.mbt new file mode 100644 index 0000000000..6ca1c62c81 --- /dev/null +++ b/argparse/positionals_test.mbt @@ -0,0 +1,403 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "declaration order controls positional parsing" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first"), + PositionArg("second"), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "bounded non-last positional remains supported" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) + + let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) +} + +///| +test "positionals dash handling and separator" { + let force_cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let forced = force_cmd.parse(argv=["a", "--x", "-y"], env=empty_env()) catch { + _ => panic() + } + assert_true(forced.values is { "tail": ["a", "--x", "-y"], .. }) + + let dashed = force_cmd.parse(argv=["--", "p", "q"], env=empty_env()) catch { + _ => panic() + } + assert_true(dashed.values is { "tail": ["p", "q"], .. }) + + let negative_cmd = @argparse.Command("demo", positionals=[PositionArg("n")]) + let negative = negative_cmd.parse(argv=["-9"], env=empty_env()) catch { + _ => panic() + } + assert_true(negative.values is { "n": ["-9"], .. }) + + try negative_cmd.parse(argv=["x", "y"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'y' for '' found; no more were expected + #| + #|Usage: demo [n] + #| + #|Arguments: + #| n + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "variadic positional keeps accepting hyphen values after first token" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("tail", num_args=ValueRange(lower=0), allow_hyphen_values=true), + ]) + let parsed = cmd.parse(argv=["a", "-b", "--mystery"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "tail": ["a", "-b", "--mystery"], .. }) +} + +///| +test "bounded positional does not greedily consume later required values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=1, upper=2)), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let two = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(two.values is { "first": ["a"], "second": ["b"], .. }) + + let three = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(three.values is { "first": ["a", "b"], "second": ["c"], .. }) +} + +///| +test "indexed non-last positional allows explicit single num_args" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=@argparse.ValueRange::single()), + PositionArg("second", num_args=@argparse.ValueRange::single()), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "bounded positional can leave later optional positional empty" { + let parsed = @argparse.Command("demo", positionals=[ + PositionArg("x", num_args=ValueRange(lower=0, upper=2)), + PositionArg("y"), + ]).parse(argv=["a"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "x": ["a"], "y"? : None, .. }) +} + +///| +test "positionals keep declaration order with ranged positional" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("late", num_args=ValueRange(lower=2, upper=2)), + PositionArg("first"), + PositionArg("mid"), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c", "d"], env=empty_env()) catch { + _ => panic() + } + assert_true( + parsed.values is { "late": ["a", "b"], "first": ["c"], "mid": ["d"], .. }, + ) +} + +///| +test "mixed indexed and unindexed positionals keep inferred order" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first"), + PositionArg("second"), + ]) + + let parsed = cmd.parse(argv=["a", "b"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "first": ["a"], "second": ["b"], .. }) +} + +///| +test "single positional parses without explicit index metadata" { + let parsed = @argparse.Command("demo", positionals=[PositionArg("late")]).parse( + argv=["x"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(parsed.values is { "late": ["x"], .. }) +} + +///| +test "positional num_args lower bound rejects missing argv values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("first", num_args=ValueRange(lower=2, upper=3)), + ]) + + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: 'first' requires at least 2 values but only 0 were provided + #| + #|Usage: demo + #| + #|Arguments: + #| first... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "positional max clamp leaves trailing value for next positional" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("items", num_args=ValueRange(lower=0, upper=2)), + PositionArg("tail"), + ]) + + let parsed = cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "items": ["a", "b"], "tail": ["c"], .. }) +} + +///| +test "allow_hyphen positional treats unknown long token as value" { + let cmd = @argparse.Command("demo", flags=[FlagArg("known", long="known")], positionals=[ + PositionArg("input", allow_hyphen_values=true), + ]) + let parsed = cmd.parse(argv=["--mystery"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.values is { "input": ["--mystery"], .. }) +} + +///| +test "non-bmp hyphen token reports unknown argument without panic" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("value")]) + try cmd.parse(argv=["-🎉"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-🎉' found + #| + #|Usage: demo [value] + #| + #|Arguments: + #| value + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "positional default values exceeding num_args upper bound are rejected" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("tags", num_args=ValueRange(lower=0, upper=2), default_values=[ + "a", "b", "c", + ]), + ]) + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: 'tags' allows at most 2 values but 3 were provided + #| + #|Usage: demo [tags...] + #| + #|Arguments: + #| tags... [default: a, b, c] + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "earlier variadic positional reserves values for a later required one" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("head", num_args=ValueRange(lower=0)), + PositionArg("tail", num_args=ValueRange(lower=2)), + ]) + try cmd.parse(argv=["only"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: 'tail' requires at least 2 values but only 1 were provided + #| + #|Usage: demo [head...] + #| + #|Arguments: + #| head... + #| tail... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "extra values past a bounded variadic positional report the variadic label" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("items", num_args=ValueRange(lower=0, upper=2)), + ]) + let ok = cmd.parse(argv=["a", "b"], env=empty_env()) catch { _ => panic() } + assert_true(ok.values is { "items": ["a", "b"], .. }) + try cmd.parse(argv=["a", "b", "c"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'c' for '' found; no more were expected + #| + #|Usage: demo [items...] + #| + #|Arguments: + #| items... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "bounded variadic positional stops accepting hyphen tokens once full" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg( + "items", + num_args=ValueRange(lower=0, upper=2), + allow_hyphen_values=true, + ), + ]) + try cmd.parse(argv=["a", "b", "-c"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-c' found + #| + #|Usage: demo [items...] + #| + #|Arguments: + #| items... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +// A leading variadic positional must reserve enough trailing tokens for a later +// positional with a minimum count, so a hyphen token is not mistaken for one of +// its values when the trailing slot still needs filling. +test "hyphen token is rejected while a later positional still needs values" { + let cmd = @argparse.Command("demo", positionals=[ + PositionArg("head", num_args=ValueRange(lower=0), allow_hyphen_values=true), + PositionArg("tail", num_args=ValueRange(lower=2)), + ]) + try cmd.parse(argv=["-x"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected argument '-x' found + #| + #|Usage: demo [head...] + #| + #|Arguments: + #| head... + #| tail... + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} diff --git a/argparse/relationships_test.mbt b/argparse/relationships_test.mbt new file mode 100644 index 0000000000..cd822efced --- /dev/null +++ b/argparse/relationships_test.mbt @@ -0,0 +1,380 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "relationships and num args" { + let requires_cmd = @argparse.Command("demo", options=[ + OptionArg("mode", long="mode", requires=["config"]), + OptionArg("config", long="config"), + ]) + + try requires_cmd.parse(argv=["--mode", "fast"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'config' (required by 'mode') + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --mode + #| --config + #| + ), + ) + } noraise { + _ => panic() + } + + let appended = @argparse.Command("demo", options=[ + OptionArg("tag", long="tag", action=Append), + ]).parse(argv=["--tag", "a", "--tag", "b", "--tag", "c"], env=empty_env()) catch { + _ => panic() + } + assert_true(appended.values is { "tag": ["a", "b", "c"], .. }) +} + +///| +test "arg groups required and multiple" { + let cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", required=true, multiple=false, args=["fast", "slow"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("slow", long="slow")], + ) + + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required arguments were not provided: + #| <--fast|--slow> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["--fast", "--slow"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: group conflict mode + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --slow + #| + #|Groups: + #| mode [required] [exclusive] --fast, --slow + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "arg groups requires and conflicts" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", args=["fast"], requires=["output"]), + ArgGroup("output", args=["json"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], + ) + + try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required arguments were not provided: + #| <--json> + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --json + #| + #|Groups: + #| mode --fast + #| output --json + #| + ), + ) + } noraise { + _ => panic() + } + + let conflict_cmd = @argparse.Command( + "demo", + groups=[ + ArgGroup("mode", args=["fast"], conflicts_with=["output"]), + ArgGroup("output", args=["json"]), + ], + flags=[FlagArg("fast", long="fast"), FlagArg("json", long="json")], + ) + + try conflict_cmd.parse(argv=["--fast", "--json"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: group conflict mode conflicts with output + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --json + #| + #|Groups: + #| mode --fast + #| output --json + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "negatable and conflicts" { + let cmd = @argparse.Command("demo", flags=[ + FlagArg("cache", long="cache", negatable=true), + FlagArg("failfast", long="failfast", action=SetFalse, negatable=true), + FlagArg("verbose", long="verbose", conflicts_with=["quiet"]), + FlagArg("quiet", long="quiet"), + ]) + + let no_cache = cmd.parse(argv=["--no-cache"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_cache.flags is { "cache": false, .. }) + assert_true(no_cache.sources is { "cache": Argv, .. }) + + let no_failfast = cmd.parse(argv=["--no-failfast"], env=empty_env()) catch { + _ => panic() + } + assert_true(no_failfast.flags is { "failfast": true, .. }) + + try cmd.parse(argv=["--verbose", "--quiet"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: conflicting arguments: verbose and quiet + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --[no-]cache + #| --[no-]failfast + #| --verbose + #| --quiet + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "group requires/conflicts can target argument names" { + let requires_cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", args=["fast"], requires=["config"])], + flags=[FlagArg("fast", long="fast")], + options=[OptionArg("config", long="config")], + ) + + let ok = requires_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(ok.flags is { "fast": true, .. }) + assert_true(ok.values is { "config": ["cfg.toml"], .. }) + + try requires_cmd.parse(argv=["--fast"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'config' + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --config + #| + #|Groups: + #| mode --fast + #| + ), + ) + } noraise { + _ => panic() + } + + let conflicts_cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", args=["fast"], conflicts_with=["config"])], + flags=[FlagArg("fast", long="fast")], + options=[OptionArg("config", long="config")], + ) + + try + conflicts_cmd.parse( + argv=["--fast", "--config", "cfg.toml"], + env=empty_env(), + ) + catch { + err => + inspect( + err, + content=( + #|error: group conflict mode conflicts with config + #| + #|Usage: demo [options] + #| + #|Options: + #| -h, --help Show help information. + #| --fast + #| --config + #| + #|Groups: + #| mode --fast + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "group without members has no parse effect" { + let cmd = @argparse.Command("demo", groups=[ArgGroup("known")], flags=[ + FlagArg("x", long="x"), + ]) + let parsed = cmd.parse(argv=["--x"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.flags is { "x": true, .. }) + let help = cmd.render_help() + assert_true(help.has_prefix("Usage: demo [options]")) +} + +///| +test "empty groups without presence do not fail" { + let grouped_ok = @argparse.Command( + "demo", + groups=[ArgGroup("left", args=["l"]), ArgGroup("right", args=["r"])], + flags=[FlagArg("l", long="left"), FlagArg("r", long="right")], + ) + let parsed = grouped_ok.parse(argv=["--left"], env=empty_env()) catch { + _ => panic() + } + assert_true(parsed.flags is { "l": true, .. }) +} + +///| +test "required and env-fed ranged values validate after parsing" { + let required_cmd = @argparse.Command("demo", options=[ + OptionArg("input", long="input", required=true), + ]) + try required_cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument was not provided: 'input' + #| + #|Usage: demo --input + #| + #|Options: + #| -h, --help Show help information. + #| --input + #| + ), + ) + } noraise { + _ => panic() + } + + let env_min_cmd = @argparse.Command("demo", options=[ + OptionArg("pair", long="pair", env="PAIR"), + ]) + let env_value = env_min_cmd.parse(argv=[], env={ "PAIR": "one" }) catch { + _ => panic() + } + assert_true(env_value.values is { "pair": ["one"], .. }) + assert_true(env_value.sources is { "pair": Env, .. }) +} + +///| +test "required group with only hidden members reports a plain message" { + let cmd = @argparse.Command( + "demo", + groups=[ArgGroup("mode", required=true, args=["fast"])], + flags=[FlagArg("fast", long="fast", hidden=true)], + ) + try cmd.parse(argv=[], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: the following required argument group was not provided: 'mode' + #| + #|Usage: demo + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} diff --git a/argparse/subcommands_test.mbt b/argparse/subcommands_test.mbt new file mode 100644 index 0000000000..6b74679eac --- /dev/null +++ b/argparse/subcommands_test.mbt @@ -0,0 +1,272 @@ +// Copyright 2026 International Digital Economy Academy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +///| +test "subcommand parsing" { + let echo = @argparse.Command("echo", positionals=[PositionArg("msg")]) + let root = @argparse.Command("root", subcommands=[echo]) + + let matches = root.parse(argv=["echo", "hi"], env=empty_env()) catch { + _ => panic() + } + assert_true( + matches.subcommand is Some(("echo", sub)) && + sub.values is { "msg": ["hi"], .. }, + ) +} + +///| +test "default subcommand dispatches through normal child parsing" { + let tui = @argparse.Command( + "tui", + about="Start interactive UI", + flags=[FlagArg("trace", short='t')], + options=[OptionArg("theme", long="theme")], + positionals=[PositionArg("workspace")], + ) + let mcp = @argparse.Command("mcp", about="Run MCP server", options=[ + OptionArg("port", long="port"), + ]) + let cmd = @argparse.Command( + "openseek", + options=[OptionArg("config", long="config", global=true)], + flags=[FlagArg("verbose", short='v', action=Count, global=true)], + subcommands=[tui, mcp], + default_subcommand="tui", + ) + + let bare = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() } + assert_true(bare.subcommand is Some(("tui", _))) + + let defaulted = cmd.parse( + argv=["--config", "config.toml", "--theme", "dark", "workspace"], + env=empty_env(), + ) catch { + _ => panic() + } + assert_true(defaulted.values is { "config": ["config.toml"], .. }) + assert_true( + defaulted.subcommand is Some(("tui", sub)) && + sub.values + is { + "config": ["config.toml"], + "theme": ["dark"], + "workspace": ["workspace"], + .. + }, + ) + + let short_global = cmd.parse(argv=["-v", "--theme", "dark"], env=empty_env()) catch { + _ => panic() + } + assert_true(short_global.flag_counts is { "verbose": 1, .. }) + assert_true( + short_global.subcommand is Some(("tui", sub)) && + sub.values is { "theme": ["dark"], .. } && + sub.flag_counts is { "verbose": 1, .. }, + ) + + let mixed_short = cmd.parse(argv=["-vt"], env=empty_env()) catch { + _ => panic() + } + assert_true(mixed_short.flag_counts is { "verbose": 1, .. }) + assert_true( + mixed_short.subcommand is Some(("tui", sub)) && + sub.flags is { "trace": true, .. } && + sub.flag_counts is { "verbose": 1, .. }, + ) + + let explicit = cmd.parse(argv=["mcp", "--port", "9000"], env=empty_env()) catch { + _ => panic() + } + assert_true( + explicit.subcommand is Some(("mcp", sub)) && + sub.values is { "port": ["9000"], .. }, + ) +} + +///| +test "default subcommand gives exact subcommands precedence over positionals" { + let cmd = @argparse.Command( + "openseek", + subcommands=[ + Command("tui", positionals=[PositionArg("workspace")]), + Command("mcp"), + ], + default_subcommand="tui", + ) + + let explicit = cmd.parse(argv=["mcp"], env=empty_env()) catch { _ => panic() } + assert_true(explicit.subcommand is Some(("mcp", _))) + + let positional = cmd.parse(argv=["project"], env=empty_env()) catch { + _ => panic() + } + assert_true( + positional.subcommand is Some(("tui", sub)) && + sub.values is { "workspace": ["project"], .. }, + ) + + let explicit_default = cmd.parse(argv=["tui", "mcp"], env=empty_env()) catch { + _ => panic() + } + assert_true( + explicit_default.subcommand is Some(("tui", sub)) && + sub.values is { "workspace": ["mcp"], .. }, + ) + + let after_dash_dash = cmd.parse(argv=["--", "mcp"], env=empty_env()) catch { + _ => panic() + } + assert_true( + after_dash_dash.subcommand is Some(("tui", sub)) && + sub.values is { "workspace": ["mcp"], .. }, + ) +} + +///| +test "subcommand cannot follow positional arguments" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + try cmd.parse(argv=["raw", "run"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: subcommand 'run' cannot be used with positional arguments + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| run + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand suggestions are only reported for parse failures" { + let cmd = @argparse.Command( + "demo", + positionals=[PositionArg("input", about="input file")], + subcommands=[Command("serve", about="serve")], + ) + let positional = cmd.parse(argv=["serv"], env=empty_env()) catch { + _ => panic() + } + assert_true(positional.values is { "input": ["serv"], .. }) + assert_true(positional.subcommand is None) + + try cmd.parse(argv=["input.txt", "serv"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'serv' for '' found; no more were expected + #| + #| tip: a similar subcommand exists: 'serve' + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input input file + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } + + let no_positionals = @argparse.Command("demo", subcommands=[ + Command("serve", about="serve"), + ]) + try no_positionals.parse(argv=["hel"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unexpected value 'hel' found; no more were expected + #| + #| tip: a similar subcommand exists: 'help' + #| + #|Usage: demo [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } + + try cmd.parse(argv=["help", "serv"], env=empty_env()) catch { + err => + inspect( + err, + content=( + #|error: unknown subcommand: serv + #| + #| tip: a similar subcommand exists: 'serve' + #| + #|Usage: demo [input] [command] + #| + #|Commands: + #| serve serve + #| help Print help for the subcommand(s). + #| + #|Arguments: + #| input input file + #| + #|Options: + #| -h, --help Show help information. + #| + ), + ) + } noraise { + _ => panic() + } +} + +///| +test "subcommand lookup falls back to positional value" { + let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[ + Command("run"), + ]) + let parsed = cmd.parse(argv=["raw"], env=empty_env()) catch { _ => panic() } + assert_true(parsed.values is { "input": ["raw"], .. }) + assert_true(parsed.subcommand is None) +}