Options declared in a command with subcommands can cause issues

It appears that options declared in a command with subcommands can cause issues.

Summary

If I got this right, the run() method of an instance of a ParsableCommand is never executed if the ParsableCommand has a subcommand.

If a ParsableCommand has an option and a subcommand, help and --help fail.

If a ParsableCommand and one of its subcommands have options with the same label (I don' know the proper term in SAP for, say, "--foo" in "--foo FOO"), you cannot even run the program without an error message. And the error message has usage that suggests exactly what caused the error in the first place.

All three are confusing, and the last two reflect either bugs or undefined behavior.

Examples

There are four examples, all of which refer to the same subcommand.

Subcommand

The subcommand never changes.

import ArgumentParser

struct Subcommand: ParsableCommand {
    static let configuration: CommandConfiguration = .init(commandName: "sub-level")
    @Option var foo: String
    @Option var bar: String
    public func run() {
        print("SubcommandInstance.foo = \(foo)")
        print("SubcommandInstance.bar = \(bar)")
    }
}

Example A

Example A shows that, apparently, the run method in a top-level command is never executed:

struct Subcommand: ParsableCommand {
    static let configuration: CommandConfiguration = .init(commandName: "sub-level")
    @Option var foo: String
    @Option var bar: String
    public func run() {
        print("SubcommandInstance.foo = \(foo)")
        print("SubcommandInstance.bar = \(bar)")
    }
}
> ./top-level sub-level --foo FOO --bar BAR
SubcommandInstance.foo = FOO
SubcommandInstance.bar = BAR

Example B

Example B shows that adding an option to the top-level messes up help, even if the option's label has nothing in common with the subcommand option names.

@main
struct TopLevelCommand_B: ParsableCommand {
    @Option var baz: String
    static var configuration: CommandConfiguration {
        .init(commandName: "top-b", subcommands: [Subcommand.self])
    }
    func run() {
        print("TopLevelCommand_B.baz = \(baz)")
    }
}

Accordng to > ./top-level --help, this should work, but it generates an error message.

> ./top-level help sub-level
Error: Missing expected argument '--baz <baz>'
Help:  --baz <baz>  
Usage: top-b --baz <baz> <subcommand>
  See 'top-b --help' for more information.

This generates help for the top-level instead of the expected help for sub-level.

> ./top-level sub-level --help
USAGE: top-b --baz <baz> <subcommand>

OPTIONS:
  --baz <baz>
  -h, --help              Show help information.

SUBCOMMANDS:
  sub-level

  See 'top-b help <subcommand>' for detailed help.

Example C

Example C shows what happens if baz is changed to bar at the top-level.

@main
struct TopLevelCommand_C: ParsableCommand {
    @Option var bar: String
    static var configuration: CommandConfiguration {
        .init(commandName: "top-c", subcommands: [Subcommand.self])
    }
    func run() {
        print("TopLevelCommand_B.baz = \(bar)")
    }
}

The help issues are the same as in Example B. But now you cannot even run the program without causing an error.

> ./top-level --bar BAR1 sub-level --foo FOO --bar BAR
Error: Missing expected argument '--bar <bar>'
Help:  --bar <bar>  
Usage: top-c sub-level --foo <foo> --bar <bar>
  See 'top-c sub-level --help' for more information.

And if you follow the suggested usage, you are in a "usage loop".

> > ./top-level sub-level --foo FOO --bar BAR
Error: Missing expected argument '--bar <bar>'
Help:  --bar <bar>  
Usage: top-c sub-level --foo <foo> --bar <bar>
  See 'top-c sub-level --help' for more information.

Example D

Example D shows that you still cannot run the program even if bar has a default value.

@main
struct TopLevelCommand_D: ParsableCommand {
    @Option var bar: String = "barDefaultValue"
    static var configuration: CommandConfiguration {
        .init(commandName: "top-d", subcommands: [Subcommand.self])
    }
    func run() {
        print("TopLevelCommand_B.baz = \(bar)")
    }
}

You still get the "usage loop":

 ./top-level sub-level --foo FOO --bar BAR
Error: Missing expected argument '--bar <bar>'
Help:  --bar <bar>  
Usage: top-d sub-level --foo <foo> --bar <bar>
  See 'top-d sub-level --help' for more information.

And this also does not work:

> ./top-level --bar BAR1  sub-level --foo FOO --bar BAR
> ./top-level --bar BAR1  sub-level --foo FOO --bar BAR
Error: Missing expected argument '--bar <bar>'
Help:  --bar <bar>  
Usage: top-d sub-level --foo <foo> --bar <bar>
  See 'top-d sub-level --help' for more information.

I see --bar twice, it is definitely not missing.

By the way, contrary to Example C, help works fine in example D.

Suggestion

Either there are a lot of bugs here, or it should be documented that declaring options in a ParsableCommand with a subcommand leads to undefined behaviour.

I would suggest the latter, since such options are, apparently (in the current version of SAP), useless.

Akin to the fatal (i.e., programmer) error that shows up in case of option label conflicts, I would suggest that a fatal error be generated if options (or a run() method) is detected in a command with a subcommand.

Thanks - I cleaned it up.

1 Like

Yes, when you run a subcommand, the top level run does not execute. I believe that is the intended behavior.

Yeah, it is a little strange that --help enforces the non-optional --baz parameter rule, even though it’s not running TopLevelCommand_B’s main run. The obvious workaround is to make --baz optional (by using String? or providing a default value). (As an aside, I might even suggest that the idea of an “option” that isn’t optional is a rather non-idiomatic.)

I agree that it would be nice if it handled that a little more elegantly, but the solution is to use an @OptionGroup for bar:

struct SharedOptions: ParsableArguments {
    @Option(name: [.long, .short])
    var bar: String
}

struct Subcommand: ParsableCommand {
    static let configuration = CommandConfiguration(commandName: "sub-level")

    @Option(name: [.long, .short])
    var foo: String

    @OptionGroup var options: SharedOptions

    func run() {
        print("SubcommandInstance.foo = \(foo)")
        print("SubcommandInstance.bar = \(options.bar)")
    }
}

@main
struct TopLevelCommand_C: ParsableCommand {
    @OptionGroup var options: SharedOptions

    static let configuration = CommandConfiguration(commandName: "top-c", subcommands: [Subcommand.self])

    func run() {
        print("TopLevelCommand_C.bar = \(options.bar)")
    }
}

That handles the scenario of --bar being an option used by both the top-level command and the subcommand.

While it solves your problem, I should acknowledge that the above still does manifest one strange behavior: If you use help, it still expects the non-optional option to be provided, which is weird:

$ ./top-c help sub-command

Error: Missing expected argument '--bar <bar>'
Help:  --bar <bar>  
Usage: top-c --bar <bar> <subcommand>
  See 'top-c --help' for more information.

I would have thought that help should bypass any option validation. But, then again, the idea of an “option” that is required is a bit of an anti-pattern, anyway, so I’m not sure if this concern will get the attention of the ArgumentParser maintainers.

The above pattern works for options that have default values, too:

struct SharedOptions: ParsableArguments {
    @Option(name: [.long, .short])
    var bar: String = "bar"
}

struct Subcommand: ParsableCommand {
    static let configuration = CommandConfiguration(commandName: "sub-level")

    @Option(name: [.long, .short])
    var foo: String = "foo"

    @OptionGroup var options: SharedOptions

    func run() {
        print("SubcommandInstance.foo = \(foo)")
        print("SubcommandInstance.bar = \(options.bar)")
    }
}

@main
struct TopLevelCommand_D: ParsableCommand {
    @OptionGroup var options: SharedOptions

    static let configuration = CommandConfiguration(commandName: "top-d", subcommands: [Subcommand.self])

    func run() {
        print("TopLevelCommand_D.bar = \(options.bar)")
    }
}

And, needless to say, this also resolves the subcommand help issue we saw when the options were not really optional:

$ ./top-d help sub-command

USAGE: top-d sub-level [--foo <foo>] [--bar <bar>]

OPTIONS:
  -f, --foo <foo>         (default: foo)
  -b, --bar <bar>         (default: bar)
  -h, --help              Show help information.