diff --git a/src/cli.toit b/src/cli.toit index eedc9f4..3afbcfc 100644 --- a/src/cli.toit +++ b/src/cli.toit @@ -344,16 +344,22 @@ class Command: The $add-ui-help flag is used to determine whether to include help for `--verbose`, ... in the help output. By default it is active if no $cli is provided. + + The $completion-as-flag parameter controls whether shell completion is exposed as a + `--generate-completion` flag or a `completion` subcommand. If null (the default), + commands with subcommands get a subcommand, and commands without get a flag. */ run arguments/List -> none --invoked-command=system.program-name --cli/Cli?=null --add-ui-help/bool=(not cli) - --add-completion/bool=true: + --add-completion/bool=true + --completion-as-flag/bool?=null: added-completion-flag := false if add-completion: added-completion-flag = add-completion-bootstrap_ --program-path=invoked-command + --as-flag=completion-as-flag // Handle __complete requests before any other processing. if add-completion and not arguments.is-empty and arguments[0] == "__complete": @@ -392,26 +398,33 @@ class Command: /** Adds a bootstrap mechanism for shell completions. - If the command has no run callback, a "completion" subcommand is added. - Otherwise, a "--generate-completion" option is added to the root command. + If $as-flag is null, the choice is automatic: commands with subcommands get a + "completion" subcommand, commands without get a "--generate-completion" flag. + If $as-flag is true, a "--generate-completion" flag is always used. + If $as-flag is false, a "completion" subcommand is always used. Returns true if the "--generate-completion" flag was added. */ - add-completion-bootstrap_ --program-path/string -> bool: + add-completion-bootstrap_ --program-path/string --as-flag/bool?=null -> bool: // Don't add if the user already has a "completion" subcommand. if find-subcommand_ "completion": return false // Don't add if the user already has a "--generate-completion" option. options_.do: | opt/Option | if opt.name == "generate-completion": return false - if not run-callback_: - add-completion-subcommand_ --program-path=program-path - return false + use-flag/bool := ? + if as-flag != null: + use-flag = as-flag + else: + // Auto: use a subcommand if there are subcommands, otherwise a flag. + use-flag = subcommands_.is-empty - // The root has a run callback, so we can't add a subcommand. Fall back - // to a "--generate-completion" flag. - add-completion-flag_ - return true + if use-flag: + add-completion-flag_ + return true + + add-completion-subcommand_ --program-path=program-path + return false add-completion-flag_: options_ = options_.copy @@ -613,6 +626,87 @@ class Command: return command return null + +/** +A command that groups two alternative dispatch paths: a set of named + subcommands and a default command. + +When arguments match a named subcommand, dispatch goes there. Otherwise + the default command handles the arguments. Each command has its own + independent options and rest arguments — there is no option inheritance + between the two. + +This is useful for commands like `toit` which accept both + subcommands (`toit run`, `toit compile`) and direct file arguments + (`toit foo.toit`). + +A $CommandGroup can be used anywhere a $Command can — including as a + nested subcommand. + +The help output shows a combined usage section followed by separate + titled sections for each alternative: + + ``` + + + Usage: + app [...] + app [] + + : + + + : + + ``` +*/ +class CommandGroup extends Command: + /** The command used when no named subcommand matches. Must have a run callback. */ + default_/Command + + /** Title shown in help above the default command's section. */ + default-title_/string + + /** The command that holds the named subcommands. Must not have a run callback. */ + commands_/Command + + /** Title shown in help above the commands section. */ + commands-title_/string + + /** + Constructs a new command group. + + The $default command handles arguments that don't match any named subcommand. + It must have a run callback. + + The $commands command holds the named subcommands. It must not have a run callback + and must have at least one subcommand. + */ + constructor name/string + --help/string?=null + --examples/List=[] + --aliases/List=[] + --hidden/bool=false + --default/Command + --default-title/string="Default" + --commands/Command + --commands-title/string="Commands": + if not default.run-callback_: + throw "The default command must have a run callback." + if commands.run-callback_: + throw "The commands command must not have a run callback." + default_ = default + default-title_ = default-title + commands_ = commands + commands-title_ = commands-title + super.private name + --help=help + --examples=examples + --aliases=aliases + --subcommands=commands.subcommands_ + --hidden=hidden + + /** A completion candidate returned by completion callbacks. diff --git a/src/completion_.toit b/src/completion_.toit index 15d7493..ea4df4a 100644 --- a/src/completion_.toit +++ b/src/completion_.toit @@ -106,6 +106,15 @@ complete_ root/Command arguments/List -> CompletionResult_: continue.repeat if arg == "--": + if current-command is CommandGroup: + // Switch to the default command before entering rest mode. + group := current-command as CommandGroup + current-command = group.default_ + is-root = false + positional-index = 0 + all-named-options.clear + all-short-options.clear + add-options-for-command_ current-command all-named-options all-short-options past-dashdash = true continue.repeat @@ -174,6 +183,20 @@ complete_ root/Command arguments/List -> CompletionResult_: in-help-mode = true all-named-options.clear all-short-options.clear + else if current-command is CommandGroup: + // CommandGroup: no matching subcommand — switch to default command. + group := current-command as CommandGroup + current-command = group.default_ + is-root = false + positional-index = 0 + all-named-options.clear + all-short-options.clear + add-options-for-command_ current-command all-named-options all-short-options + // Record this arg as the first rest argument. + rest-option := rest-option-for-index_ current-command positional-index + if rest-option: + (seen-options.get rest-option.name --init=:[]).add arg + positional-index++ else: // It's a positional/rest argument. Record its value under its // owning rest option's name so that completion callbacks can @@ -253,6 +276,10 @@ complete_ root/Command arguments/List -> CompletionResult_: return complete-option-names_ current-command all-named-options seen-options current-word // Completing a subcommand or rest argument. + if current-command is CommandGroup: + // CommandGroup: suggest both subcommands and the default command's rest. + group := current-command as CommandGroup + return complete-subcommands-and-rest_ current-command group.default_ all-named-options seen-options current-word --is-root=is-root if not current-command.run-callback_: return complete-subcommands_ current-command all-named-options seen-options current-word --is-root=is-root else: @@ -343,6 +370,47 @@ complete-subcommands_ command/Command all-named-options/Map seen-options/Map cur return CompletionResult_ candidates --directive=DIRECTIVE-NO-FILE-COMPLETION_ +/** +Completes both named subcommands and the default command's rest arguments + for a $CommandGroup. + +The subcommand candidates come from $command, and the rest + directive/extensions come from $default-command. +*/ +complete-subcommands-and-rest_ command/Command default-command/Command all-named-options/Map seen-options/Map current-word/string --is-root/bool -> CompletionResult_: + candidates := [] + + command.subcommands_.do: | sub/Command | + if sub.is-hidden_: continue.do + if sub.name.starts-with current-word: + candidates.add (CompletionCandidate_ sub.name --description=sub.short-help) + sub.aliases_.do: | alias/string | + if alias.starts-with current-word: + candidates.add (CompletionCandidate_ alias --description=sub.short-help) + + if is-root and "help".starts-with current-word: + candidates.add (CompletionCandidate_ "help" --description="Show help for a command.") + + if current-word.starts-with "-": + all-named-options.do: | name/string option/Option | + if option.is-hidden: continue.do + if (seen-options.contains name) and not option.is-multi: continue.do + long-name := "--$name" + if long-name.starts-with current-word: + candidates.add (CompletionCandidate_ long-name --description=option.help) + + has-help-option := all-named-options.contains "help" + has-h-short := all-named-options.any: | _ option/Option | option.short-name == "h" + if not has-help-option and "--help".starts-with current-word: + candidates.add (CompletionCandidate_ "--help" --description="Show help for this command.") + if not has-h-short and "-h".starts-with current-word: + candidates.add (CompletionCandidate_ "-h" --description="Show help for this command.") + + // Add rest completions from the default command. + rest-result := complete-rest_ default-command seen-options current-word + candidates.add-all rest-result.candidates + return CompletionResult_ candidates --directive=rest-result.directive --extensions=rest-result.extensions + /** Completes rest arguments. diff --git a/src/help-generator_.toit b/src/help-generator_.toit index 77b3681..d366c65 100644 --- a/src/help-generator_.toit +++ b/src/help-generator_.toit @@ -167,6 +167,9 @@ class HelpGenerator: Builds the full help for the command that was given to the constructor. */ build-all: + if command_ is CommandGroup: + build-command-group_ + return build-description build-usage build-aliases @@ -244,17 +247,7 @@ class HelpGenerator: else if not option.is-hidden: has-more-options = true - if not command_.subcommands_.is-empty: write_ " " - if has-more-options: write_ " []" - if not command_.rest_.is-empty: write_ " [--]" - command_.rest_.do: | option/Option | - type := option.type - option-str/string := ? - if type == "string": option-str = "<$option.name>" - else: option-str = "<$option.name:$option.type>" - if option.is-multi: option-str = "$option-str..." - if not option.is-required: option-str = "[$option-str]" - write_ " $option-str" + write-usage-suffix_ command_ --has-more-options=has-more-options if as-section: writeln_ /** @@ -678,6 +671,107 @@ class HelpGenerator: if in-quotes: throw "Unterminated quotes: $arguments-string.trim" return arguments + /** + Builds a full help section for an inner command of a $CommandGroup. + + Uses the $path_ prefix for display. Includes description, usage, + commands, options, and rest — indented under the section title. + */ + build-section-for_ command/Command --title/string -> none: + ensure-vertical-space_ + writeln_ "$title:" + + if help := command.help_: + writeln_ help.trim --indentation=2 + writeln_ + else if short-help := command.short-help_: + writeln_ short-help.trim --indentation=2 + writeln_ + + write_ "Usage:" --indentation=2 + writeln_ + build-usage-for-inner_ command --indentation=4 + + if not command.subcommands_.is-empty: + writeln_ + write_ "Commands:" --indentation=2 + writeln_ + commands-and-help := [] + command.subcommands_.do: | subcommand/Command | + if subcommand.is-hidden_: continue.do + commands-and-help.add [subcommand.name, subcommand.short-help] + sorted-commands := commands-and-help.sort: | a/List b/List | a[0].compare-to b[0] + write-table_ sorted-commands --indentation=4 + + build-options_ --title=" Options" command.options_ --add-help + + if not command.rest_.is-empty: + build-options_ --title=" Rest" command.rest_ --rest + + /** + Builds a usage line for an inner command, with the given $indentation. + + Uses the $path_ prefix for the invoked command name, then appends + the $command's own options, subcommands, and rest arguments. + */ + build-usage-for-inner_ command/Command --indentation/int -> none: + write_ path_.invoked-command --indentation=indentation + for i := 1; i < path_.size; i++: + write_ " $path_[i].name" + + has-more-options := false + command.options_.do: | option/Option | + if option.is-required: + write_ " --$option.name" + if not option.is-flag: + write_ "=<$option.type>" + else if not option.is-hidden: + has-more-options = true + + write-usage-suffix_ command --has-more-options=has-more-options + writeln_ + + /** + Writes the trailing portion of a usage line: ``, `[]`, + `[--]`, and rest arguments. + */ + write-usage-suffix_ command/Command --has-more-options/bool -> none: + if not command.subcommands_.is-empty: write_ " " + if has-more-options: write_ " []" + if not command.rest_.is-empty: write_ " [--]" + command.rest_.do: | option/Option | + type := option.type + option-str/string := ? + if type == "string": option-str = "<$option.name>" + else: option-str = "<$option.name:$option.type>" + if option.is-multi: option-str = "$option-str..." + if not option.is-required: option-str = "[$option-str]" + write_ " $option-str" + + /** + Builds the help for a $CommandGroup. + + Shows a combined usage section followed by titled sections for the + default command and the commands command. + */ + build-command-group_ -> none: + group := command_ as CommandGroup + + // Top-level description from the group itself. + build-description + + // Combined usage section. + ensure-vertical-space_ + writeln_ "Usage:" + build-usage-for-inner_ group.default_ --indentation=2 + build-usage-for-inner_ group.commands_ --indentation=2 + + // Default command section. + build-section-for_ group.default_ --title=group.default-title_ + + // Commands command section. + build-section-for_ group.commands_ --title=group.commands-title_ + write_ str/string: buffer_.add str diff --git a/src/parser_.toit b/src/parser_.toit index 0c92901..5ceb360 100644 --- a/src/parser_.toit +++ b/src/parser_.toit @@ -105,6 +105,12 @@ class Parser_: while index < arguments.size: argument/string := arguments[index++] if argument == "--": + // If we're on a CommandGroup, dispatch to the default command. + if command is CommandGroup: + group := command as CommandGroup + remaining := ["--"] + arguments[index..] + parse group.default_ remaining block + return rest.add-all arguments[index ..] break // We're done! @@ -125,6 +131,13 @@ class Parser_: option := all-named-options.get kebab-name if not option: if name == "help" and not is-inverted: return-help.call [] + // If we're on a CommandGroup, an unknown option means we should + // dispatch all arguments from here onwards to the default command. + if command is CommandGroup: + group := command as CommandGroup + remaining := [argument] + arguments[index..] + parse group.default_ remaining block + return fatal path "Unknown option: --$name" if option.is-flag and value != null: @@ -159,6 +172,11 @@ class Parser_: if not option: if short-name == "h": return-help.call [] + if command is CommandGroup: + group := command as CommandGroup + remaining := [argument] + arguments[index..] + parse group.default_ remaining block + return fatal path "Unknown option: -$short-name" i += option-length @@ -182,6 +200,14 @@ class Parser_: // Special case for the help command. return-help.call arguments[index..] + // If this is a CommandGroup, re-dispatch all remaining arguments + // (including the current one) to the default command. + if command is CommandGroup: + group := command as CommandGroup + remaining := [argument] + arguments[index..] + parse group.default_ remaining block + return + fatal path "Unknown command: $argument" set-command.call subcommand true @@ -208,6 +234,10 @@ class Parser_: fatal path "Unexpected rest argument: '$rest[rest-index]'." if not command.run-callback_: + if command is CommandGroup: + group := command as CommandGroup + parse group.default_ [] block + return fatal path "Missing subcommand." block.call path (Parameters.private_ options seen-options) diff --git a/tests/completion_test.toit b/tests/completion_test.toit index 0d103bb..79237f2 100644 --- a/tests/completion_test.toit +++ b/tests/completion_test.toit @@ -41,6 +41,9 @@ main: test-option-extensions test-help-completion test-help-gated-on-availability + test-command-group-completion + test-command-group-after-default-entered + test-command-group-with-extensions test-empty-input: root := cli.Command "app" @@ -776,3 +779,91 @@ test-help-gated-on-availability: expect-equals 1 (values.filter: it == "-h").size // --help should still appear since "help" as a name is not taken. expect (values.contains "--help") + +test-command-group-completion: + // A CommandGroup should suggest both subcommands and default rest completions. + default-cmd := cli.Command "default" + --rest=[ + cli.OptionEnum "source" ["main.toit", "test.toit"] + --help="Source file.", + ] + --run=:: null + commands-cmd := cli.Command "commands" + commands-cmd.add (cli.Command "serve" --help="Start a server." --run=:: null) + commands-cmd.add (cli.Command "build" --help="Build the project." --run=:: null) + + root := cli.CommandGroup "app" + --default=default-cmd + --commands=commands-cmd + + // Empty prefix: should see subcommands + rest completions + help. + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "serve") + expect (values.contains "build") + expect (values.contains "help") + expect (values.contains "main.toit") + expect (values.contains "test.toit") + + // Prefix matching subcommand. + result = complete_ root ["s"] + values = result.candidates.map: it.value + expect (values.contains "serve") + expect (not (values.contains "build")) + + // Prefix matching rest. + result = complete_ root ["m"] + values = result.candidates.map: it.value + expect (values.contains "main.toit") + expect (not (values.contains "serve")) + + // Options with "-" prefix. + result = complete_ root ["-"] + values = result.candidates.map: it.value + expect (values.contains "--help") + expect (values.contains "-h") + +test-command-group-after-default-entered: + // After an arg that doesn't match a subcommand, the default command + // is entered. Subsequent completions should come from the default. + default-cmd := cli.Command "default" + --rest=[ + cli.Option "source" --help="Source file.", + cli.OptionEnum "mode" ["debug", "release"] --multi --help="Mode.", + ] + --run=:: null + commands-cmd := cli.Command "commands" + commands-cmd.add (cli.Command "serve" --help="Start a server." --run=:: null) + + root := cli.CommandGroup "app" + --default=default-cmd + --commands=commands-cmd + + // After "foo.toit" (doesn't match a subcommand), completing the second arg. + result := complete_ root ["foo.toit", ""] + values := result.candidates.map: it.value + expect (values.contains "debug") + expect (values.contains "release") + expect (not (values.contains "serve")) + +test-command-group-with-extensions: + // CommandGroup where default has file extensions. + default-cmd := cli.Command "default" + --rest=[ + cli.OptionPath "source" --extensions=[".toit"] --help="Source file.", + ] + --run=:: null + commands-cmd := cli.Command "commands" + commands-cmd.add (cli.Command "run" --help="Run something." --run=:: null) + + root := cli.CommandGroup "app" + --default=default-cmd + --commands=commands-cmd + + result := complete_ root [""] + values := result.candidates.map: it.value + expect (values.contains "run") + expect-equals DIRECTIVE-FILE-COMPLETION_ result.directive + expect-equals 1 result.extensions.size + expect (result.extensions.contains ".toit") + diff --git a/tests/help_test.toit b/tests/help_test.toit index c56714b..e7fb4b7 100644 --- a/tests/help_test.toit +++ b/tests/help_test.toit @@ -17,6 +17,7 @@ main: test-examples test-short-help test-help-all + test-command-group-help check-output expected/string [block]: ui := TestUi @@ -936,3 +937,73 @@ test-help-all: root.run ["help", "--all", "execute"] --cli=cli-obj --invoked-command="bin/app" all-output := ui.stdout + ui.stderr expect-equals (expected + "\n") all-output + +test-command-group-help: + default-cmd := cli.Command "default" + --help="Run a source file directly." + --options=[ + cli.OptionInt "optimization-level" --short-name="O" --default=1 + --help="Set the optimization level.", + ] + --rest=[ + cli.Option "source" --help="The source file." --required, + cli.Option "arg" --help="Arguments." --multi, + ] + --run=:: null + + commands-cmd := cli.Command "commands" + --help="Use a subcommand." + --options=[ + cli.Flag "verbose" --short-name="v" --help="Be verbose.", + ] + sub-run := cli.Command "run" --help="Run a file." --run=:: null + sub-compile := cli.Command "compile" --help="Compile a file." --run=:: null + commands-cmd.add sub-run + commands-cmd.add sub-compile + + root := cli.CommandGroup "app" + --help="A test application." + --default=default-cmd + --default-title="Run a file" + --commands=commands-cmd + --commands-title="Subcommands" + + expected := """ + A test application. + + Usage: + bin/app [] [--] [...] + bin/app [] + + Run a file: + Run a source file directly. + + Usage: + bin/app [] [--] [...] + + Options: + -h, --help Show help for this command. + -O, --optimization-level int Set the optimization level. (default: 1) + + Rest: + arg string Arguments. (multi) + source string The source file. (required) + + Subcommands: + Use a subcommand. + + Usage: + bin/app [] + + Commands: + compile Compile a file. + completion Generate shell completion scripts. + run Run a file. + + Options: + -h, --help Show help for this command. + -v, --verbose Be verbose. + """ + check-output expected: | cli/cli.Cli | + root.run ["--help"] --cli=cli --invoked-command="bin/app" + diff --git a/tests/parser_test.toit b/tests/parser_test.toit index 83ab028..5aa436b 100644 --- a/tests/parser_test.toit +++ b/tests/parser_test.toit @@ -4,6 +4,8 @@ import cli import cli.test show * +import cli.help-generator_ show HelpGenerator +import cli.path_ show Path import expect show * @@ -24,6 +26,9 @@ main: test-dash-arg test-mixed-rest-named test-snake-kebab + test-command-group + test-command-group-nested + test-command-group-help-flag test-options: expected /Map? := null @@ -414,3 +419,149 @@ test-snake-kebab: cmd.run ["--foo-bar", "foo_value", "--toto-titi", "toto_value"] cmd.run ["--foo_bar", "foo_value", "--toto_titi", "toto_value"] cmd.run ["--foo-bar", "foo_value", "--toto_titi", "toto_value"] + +test-command-group: + sub-invoked := false + default-invoked := false + default-expected /Map? := null + + default-cmd := cli.Command "default" + --rest=[ + cli.Option "source" --required, + cli.Option "arg" --multi, + ] + --options=[ + cli.OptionInt "optimization-level" --short-name="O" --default=1, + ] + --run=:: | invocation/cli.Invocation | + default-invoked = true + check-arguments default-expected invocation + + commands-cmd := cli.Command "commands" + --options=[ + cli.Flag "verbose" --short-name="v", + ] + sub := cli.Command "sub" + --help="A subcommand." + --run=:: | invocation/cli.Invocation | + sub-invoked = true + commands-cmd.add sub + + root := cli.CommandGroup "root" + --default=default-cmd + --commands=commands-cmd + + // Matching a subcommand dispatches to the commands command. + sub-invoked = false + root.run ["sub"] + expect sub-invoked + + // Non-matching argument dispatches to the default command. + default-invoked = false + default-expected = {"source": "foo.toit", "arg": [], "optimization-level": 1} + root.run ["foo.toit"] + expect default-invoked + + // Default command with its own options. + default-invoked = false + default-expected = {"source": "foo.toit", "arg": [], "optimization-level": 2} + root.run ["-O", "2", "foo.toit"] + expect default-invoked + + // Default command with rest args. + default-invoked = false + default-expected = {"source": "foo.toit", "arg": ["x", "y"], "optimization-level": 1} + root.run ["foo.toit", "x", "y"] + expect default-invoked + + // Commands command options don't leak into default. + default-invoked = false + sub-invoked = false + root.run ["--verbose", "sub"] + expect sub-invoked + expect (not default-invoked) + + // Using -- dispatches to default. + default-invoked = false + default-expected = {"source": "sub", "arg": [], "optimization-level": 1} + root.run ["--", "sub"] + expect default-invoked + +test-command-group-nested: + // A CommandGroup used as a nested subcommand. + inner-default-invoked := false + inner-sub-invoked := false + inner-default-expected /Map? := null + + inner-default := cli.Command "inner-default" + --rest=[ + cli.Option "file" --required, + ] + --run=:: | invocation/cli.Invocation | + inner-default-invoked = true + check-arguments inner-default-expected invocation + + inner-commands := cli.Command "inner-commands" + inner-sub := cli.Command "start" + --help="Start something." + --run=:: | invocation/cli.Invocation | + inner-sub-invoked = true + inner-commands.add inner-sub + + inner-group := cli.CommandGroup "tool" + --help="Tool commands." + --default=inner-default + --commands=inner-commands + + outer := cli.Command "app" + outer.add inner-group + + // Nested: subcommand dispatches correctly. + inner-sub-invoked = false + outer.run ["tool", "start"] + expect inner-sub-invoked + + // Nested: default dispatches correctly. + inner-default-invoked = false + inner-default-expected = {"file": "foo.toit"} + outer.run ["tool", "foo.toit"] + expect inner-default-invoked + +test-command-group-help-flag: + // --help and -h on a CommandGroup should show the group's help, + // not dispatch to the default command. + default-invoked := false + + default-cmd := cli.Command "default" + --help="Default command." + --rest=[ + cli.Option "source" --required, + ] + --run=:: default-invoked = true + + commands-cmd := cli.Command "commands" + commands-cmd.add (cli.Command "sub" --help="A subcommand." --run=:: null) + + root := cli.CommandGroup "app" + --help="The app." + --default=default-cmd + --commands=commands-cmd + + // -h should show help, not dispatch to default. + ui := TestUi + cli-obj := cli.Cli "test" --ui=ui + root.run ["-h"] --cli=cli-obj --invoked-command="app" + expect (not default-invoked) + // The output should contain the group's help. + output := ui.stdout + ui.stderr + expect (output.contains "The app.") + + // --help should also show help, not dispatch to default. + default-invoked = false + ui = TestUi + cli-obj = cli.Cli "test" --ui=ui + root.run ["--help"] --cli=cli-obj --invoked-command="app" + expect (not default-invoked) + output = ui.stdout + ui.stderr + expect (output.contains "The app.") +