Skip to content

Commit 591ac3c

Browse files
peter-jerry-yebobzhang
authored andcommitted
feat(argparse): add default subcommand dispatch
1 parent 618e29a commit 591ac3c

6 files changed

Lines changed: 384 additions & 42 deletions

File tree

argparse/argparse_blackbox_test.mbt

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,267 @@ test "global flag keeps parent argv over child env fallback" {
330330
)
331331
}
332332

333+
///|
334+
test "default subcommand dispatches through normal child parsing" {
335+
let tui = @argparse.Command(
336+
"tui",
337+
about="Start interactive UI",
338+
flags=[FlagArg("trace", short='t')],
339+
options=[OptionArg("theme", long="theme")],
340+
positionals=[PositionArg("workspace")],
341+
)
342+
let mcp = @argparse.Command("mcp", about="Run MCP server", options=[
343+
OptionArg("port", long="port"),
344+
])
345+
let cmd = @argparse.Command(
346+
"openseek",
347+
options=[OptionArg("config", long="config", global=true)],
348+
flags=[FlagArg("verbose", short='v', action=Count, global=true)],
349+
subcommands=[tui, mcp],
350+
default_subcommand="tui",
351+
)
352+
353+
let bare = cmd.parse(argv=[], env=empty_env()) catch { _ => panic() }
354+
assert_true(bare.subcommand is Some(("tui", _)))
355+
356+
let defaulted = cmd.parse(
357+
argv=["--config", "config.toml", "--theme", "dark", "workspace"],
358+
env=empty_env(),
359+
) catch {
360+
_ => panic()
361+
}
362+
assert_true(defaulted.values is { "config": ["config.toml"], .. })
363+
assert_true(
364+
defaulted.subcommand is Some(("tui", sub)) &&
365+
sub.values
366+
is {
367+
"config": ["config.toml"],
368+
"theme": ["dark"],
369+
"workspace": ["workspace"],
370+
..
371+
},
372+
)
373+
374+
let short_global = cmd.parse(argv=["-v", "--theme", "dark"], env=empty_env()) catch {
375+
_ => panic()
376+
}
377+
assert_true(short_global.flag_counts is { "verbose": 1, .. })
378+
assert_true(
379+
short_global.subcommand is Some(("tui", sub)) &&
380+
sub.values is { "theme": ["dark"], .. } &&
381+
sub.flag_counts is { "verbose": 1, .. },
382+
)
383+
384+
let mixed_short = cmd.parse(argv=["-vt"], env=empty_env()) catch {
385+
_ => panic()
386+
}
387+
assert_true(mixed_short.flag_counts is { "verbose": 1, .. })
388+
assert_true(
389+
mixed_short.subcommand is Some(("tui", sub)) &&
390+
sub.flags is { "trace": true, .. } &&
391+
sub.flag_counts is { "verbose": 1, .. },
392+
)
393+
394+
let explicit = cmd.parse(argv=["mcp", "--port", "9000"], env=empty_env()) catch {
395+
_ => panic()
396+
}
397+
assert_true(
398+
explicit.subcommand is Some(("mcp", sub)) &&
399+
sub.values is { "port": ["9000"], .. },
400+
)
401+
}
402+
403+
///|
404+
test "default subcommand gives exact subcommands precedence over positionals" {
405+
let cmd = @argparse.Command(
406+
"openseek",
407+
subcommands=[
408+
Command("tui", positionals=[PositionArg("workspace")]),
409+
Command("mcp"),
410+
],
411+
default_subcommand="tui",
412+
)
413+
414+
let explicit = cmd.parse(argv=["mcp"], env=empty_env()) catch { _ => panic() }
415+
assert_true(explicit.subcommand is Some(("mcp", _)))
416+
417+
let positional = cmd.parse(argv=["project"], env=empty_env()) catch {
418+
_ => panic()
419+
}
420+
assert_true(
421+
positional.subcommand is Some(("tui", sub)) &&
422+
sub.values is { "workspace": ["project"], .. },
423+
)
424+
425+
let explicit_default = cmd.parse(argv=["tui", "mcp"], env=empty_env()) catch {
426+
_ => panic()
427+
}
428+
assert_true(
429+
explicit_default.subcommand is Some(("tui", sub)) &&
430+
sub.values is { "workspace": ["mcp"], .. },
431+
)
432+
433+
let after_dash_dash = cmd.parse(argv=["--", "mcp"], env=empty_env()) catch {
434+
_ => panic()
435+
}
436+
assert_true(
437+
after_dash_dash.subcommand is Some(("tui", sub)) &&
438+
sub.values is { "workspace": ["mcp"], .. },
439+
)
440+
}
441+
442+
///|
443+
test "default subcommand help annotation and child error context" {
444+
let cmd = @argparse.Command(
445+
"openseek",
446+
options=[OptionArg("config", long="config", about="config", global=true)],
447+
subcommands=[
448+
Command("tui", about="Start interactive UI", options=[
449+
OptionArg("theme", long="theme", about="theme"),
450+
]),
451+
Command("mcp", about="Run MCP server"),
452+
],
453+
default_subcommand="tui",
454+
)
455+
456+
inspect(
457+
cmd.render_help(),
458+
content=(
459+
#|Usage: openseek [options] [command]
460+
#|
461+
#|Commands:
462+
#| tui Start interactive UI (default)
463+
#| mcp Run MCP server
464+
#| help Print help for the subcommand(s).
465+
#|
466+
#|Options:
467+
#| -h, --help Show help information.
468+
#| --config <config> config
469+
#|
470+
),
471+
)
472+
473+
try cmd.parse(argv=["--unknown"], env=empty_env()) catch {
474+
err =>
475+
inspect(
476+
err,
477+
content=(
478+
#|error: unexpected argument '--unknown' found
479+
#|
480+
#|Usage: openseek tui [options]
481+
#|
482+
#|Start interactive UI
483+
#|
484+
#|Options:
485+
#| -h, --help Show help information.
486+
#| --config <config> config
487+
#| --theme <theme> theme
488+
#|
489+
),
490+
)
491+
} noraise {
492+
_ => panic()
493+
}
494+
}
495+
496+
///|
497+
test "default subcommand validation rejects ambiguous root configuration" {
498+
try
499+
@argparse.Command(
500+
"demo",
501+
subcommands=[Command("run")],
502+
default_subcommand="missing",
503+
).parse(argv=[], env=empty_env())
504+
catch {
505+
err =>
506+
inspect(
507+
err,
508+
content=(
509+
#|error: command definition validation failed: default_subcommand must name a visible subcommand: missing
510+
),
511+
)
512+
} noraise {
513+
_ => panic()
514+
}
515+
516+
try
517+
@argparse.Command(
518+
"demo",
519+
subcommands=[Command("run")],
520+
subcommand_required=true,
521+
default_subcommand="run",
522+
).parse(argv=[], env=empty_env())
523+
catch {
524+
err =>
525+
inspect(
526+
err,
527+
content=(
528+
#|error: command definition validation failed: default_subcommand cannot be used with subcommand_required
529+
),
530+
)
531+
} noraise {
532+
_ => panic()
533+
}
534+
535+
try
536+
@argparse.Command(
537+
"demo",
538+
subcommands=[Command("run")],
539+
arg_required_else_help=true,
540+
default_subcommand="run",
541+
).parse(argv=[], env=empty_env())
542+
catch {
543+
err =>
544+
inspect(
545+
err,
546+
content=(
547+
#|error: command definition validation failed: default_subcommand cannot be used with arg_required_else_help
548+
),
549+
)
550+
} noraise {
551+
_ => panic()
552+
}
553+
554+
try
555+
@argparse.Command(
556+
"demo",
557+
options=[OptionArg("mode", long="mode")],
558+
subcommands=[Command("run")],
559+
default_subcommand="run",
560+
).parse(argv=[], env=empty_env())
561+
catch {
562+
err =>
563+
inspect(
564+
err,
565+
content=(
566+
#|error: command definition validation failed: default_subcommand only supports global root flags/options
567+
),
568+
)
569+
} noraise {
570+
_ => panic()
571+
}
572+
573+
try
574+
@argparse.Command(
575+
"demo",
576+
flags=[FlagArg("verbose", long="verbose", global=true)],
577+
groups=[ArgGroup("verbosity", args=["verbose"])],
578+
subcommands=[Command("run")],
579+
default_subcommand="run",
580+
).parse(argv=[], env=empty_env())
581+
catch {
582+
err =>
583+
inspect(
584+
err,
585+
content=(
586+
#|error: command definition validation failed: default_subcommand does not support root groups
587+
),
588+
)
589+
} noraise {
590+
_ => panic()
591+
}
592+
}
593+
333594
///|
334595
test "subcommand cannot follow positional arguments" {
335596
let cmd = @argparse.Command("demo", positionals=[PositionArg("input")], subcommands=[

argparse/command.mbt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub struct Command {
2626
priv disable_help_subcommand : Bool
2727
priv arg_required_else_help : Bool
2828
priv subcommand_required : Bool
29+
priv default_subcommand : String?
2930
priv hidden : Bool
3031
priv mut build_error : ArgBuildError?
3132
} derive(@debug.Debug)
@@ -41,6 +42,7 @@ pub struct Command {
4142
/// - `disable_help_subcommand` disables built-in `help <subcommand>` routing.
4243
/// - `arg_required_else_help=true` prints help when no argv tokens are provided.
4344
/// - `subcommand_required=true` requires selecting a subcommand.
45+
/// - `default_subcommand` dispatches missing subcommands to a visible child.
4446
/// - `hidden=true` omits this command from parent command listings.
4547
#alias(new, deprecated="Use `Command()` instead")
4648
pub fn Command::Command(
@@ -58,6 +60,7 @@ pub fn Command::Command(
5860
subcommand_required? : Bool = false,
5961
hidden? : Bool = false,
6062
groups? : ArrayView[ArgGroup] = [],
63+
default_subcommand? : StringView,
6164
) -> Command {
6265
let (parsed_args, arg_error) = collect_args(flags, options, positionals)
6366
let groups = groups.to_owned()
@@ -73,6 +76,7 @@ pub fn Command::Command(
7376
disable_help_subcommand,
7477
arg_required_else_help,
7578
subcommand_required,
79+
default_subcommand: default_subcommand.map(v => v.to_owned()),
7680
hidden,
7781
build_error: arg_error,
7882
}

argparse/help_render.mbt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,17 @@ fn positional_entries(cmd : Command) -> Array[String] {
240240
fn subcommand_entries(cmd : Command) -> Array[String] {
241241
let display = [
242242
for sub in cmd.subcommands if !sub.hidden => {
243-
(sub.name, sub.about.unwrap_or(""))
243+
let doc = sub.about.unwrap_or("")
244+
let doc = if cmd.default_subcommand is Some(name) && name == sub.name {
245+
if doc == "" {
246+
"(default)"
247+
} else {
248+
"\{doc} (default)"
249+
}
250+
} else {
251+
doc
252+
}
253+
(sub.name, doc)
244254
}
245255
]
246256
if help_subcommand_enabled(cmd) {

0 commit comments

Comments
 (0)