Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 105 additions & 11 deletions src/cli.toit
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

```
<top-level help>

Usage:
app <source> [<arg>...]
app <command> [<options>]

<default-title>:
<help for default command>

<commands-title>:
<help for commands command>
```
*/
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.

Expand Down
68 changes: 68 additions & 0 deletions src/completion_.toit
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
116 changes: 105 additions & 11 deletions src/help-generator_.toit
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -244,17 +247,7 @@ class HelpGenerator:
else if not option.is-hidden:
has-more-options = true

if not command_.subcommands_.is-empty: write_ " <command>"
if has-more-options: write_ " [<options>]"
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_

/**
Expand Down Expand Up @@ -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: `<command>`, `[<options>]`,
`[--]`, and rest arguments.
*/
write-usage-suffix_ command/Command --has-more-options/bool -> none:
if not command.subcommands_.is-empty: write_ " <command>"
if has-more-options: write_ " [<options>]"
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

Expand Down
Loading