diff --git a/examples/cli_groups.v b/examples/cli_groups.v new file mode 100644 index 00000000000000..c96dc8e74b1288 --- /dev/null +++ b/examples/cli_groups.v @@ -0,0 +1,122 @@ +// This example demonstrates the `vlib/cli` features that show up automatically +// in `--help` when you populate the optional `group`, `examples` and +// `learn_more` fields of `Command`. No opt-in is required — leaving any of +// them empty simply skips the corresponding section. +// +// Compile from the V repo root: +// v -o tasky examples/cli_groups.v +// +// Try it out: +// ./tasky --help # root help with grouped commands and examples +// ./tasky issue --help # sub-command help with INHERITED FLAGS +// ./tasky issue list -v +// ./tasky version +module main + +import cli +import os + +fn main() { + mut app := cli.Command{ + name: 'tasky' + description: 'A tiny issue tracker CLI' + version: '0.1.0' + posix_mode: true + examples: [ + '\$ tasky issue list', + '\$ tasky issue create --title "Fix CI"', + '\$ tasky config get editor', + ] + learn_more: 'Use `tasky --help` for details about a command.\nDocumentation lives at https://example.test/tasky' + } + app.add_flag(cli.Flag{ + flag: .string + name: 'config' + abbrev: 'c' + description: 'Path to a tasky config file' + global: true + }) + app.add_command(issue_command()) + app.add_command(config_command()) + app.setup() + app.parse(os.args) +} + +fn issue_command() cli.Command { + return cli.Command{ + name: 'issue' + description: 'Work with issues' + group: 'Core commands' + commands: [ + cli.Command{ + name: 'list' + description: 'List open issues' + execute: issue_list + flags: [ + cli.Flag{ + flag: .bool + name: 'verbose' + abbrev: 'v' + description: 'Show issue bodies in addition to titles' + }, + ] + }, + cli.Command{ + name: 'create' + description: 'Create a new issue' + execute: issue_create + flags: [ + cli.Flag{ + flag: .string + name: 'title' + abbrev: 't' + description: 'Title of the issue to create' + required: true + }, + ] + }, + ] + } +} + +fn config_command() cli.Command { + return cli.Command{ + name: 'config' + description: 'Read or write tasky settings' + group: 'Additional commands' + commands: [ + cli.Command{ + name: 'get' + description: 'Print the value of a config key' + usage: '' + required_args: 1 + execute: config_get + }, + cli.Command{ + name: 'set' + description: 'Update a config key' + usage: ' ' + required_args: 2 + execute: config_set + }, + ] + } +} + +fn issue_list(cmd cli.Command) ! { + verbose := cmd.flags.get_bool('verbose') or { false } + println('Listing issues (verbose=${verbose})') +} + +fn issue_create(cmd cli.Command) ! { + title := cmd.flags.get_string('title')! + println('Created issue: ${title}') +} + +fn config_get(cmd cli.Command) ! { + println('config get ${cmd.args[0]}') +} + +fn config_set(cmd cli.Command) ! { + println('config set ${cmd.args[0]} = ${cmd.args[1]}') +} diff --git a/vlib/cli/README.md b/vlib/cli/README.md index 432560d0bd9932..942bd22c1500c5 100644 --- a/vlib/cli/README.md +++ b/vlib/cli/README.md @@ -40,3 +40,26 @@ fn main() { Subcommands can set `alias` to accept a shorter invocation token, for example `example-app s`. + +## Help layout + +`--help` is generated automatically. Beyond `Usage:`, `Flags:` and `Commands:` +the output picks up extra sections when the relevant `Command` fields are +populated: + +- **`Inherited flags:`** — flags inherited from any ancestor through + `Flag.global`. Renders separately from local flags so a sub-command's + own surface stays readable. +- **Grouped commands** — set `group` on a sub-command to lift it out of + the default `Commands:` block into a section named after the group. + The group string is rendered verbatim followed by `:`, so capitalisation + is the caller's choice. Sub-commands sharing a group keep their + declaration order. +- **`Examples:`** — set `examples []string` on a `Command`; each entry + becomes one indented line. +- **`Learn more:`** — set `learn_more string`; newlines split the block + into separate lines. + +Sections that have nothing to render are simply omitted, so existing apps +see no change unless they opt in by setting these fields. See +`examples/cli_groups.v` for a runnable program that exercises all of them. diff --git a/vlib/cli/command.v b/vlib/cli/command.v index ec921f4f62a288..f6d23024d851d4 100644 --- a/vlib/cli/command.v +++ b/vlib/cli/command.v @@ -18,19 +18,33 @@ pub mut: description string man_description string version string - pre_execute FnCommandCallback = unsafe { nil } - execute FnCommandCallback = unsafe { nil } - post_execute FnCommandCallback = unsafe { nil } - disable_flags bool - sort_flags bool - sort_commands bool - parent &Command = unsafe { nil } - commands []Command - flags []Flag - required_args int - args []string - posix_mode bool - defaults struct { + // group is the section title under which `--help` lists this sub-command + // in its parent's command listing. Empty falls back to the default + // `Commands:` section. The string is rendered verbatim followed by `:` + // — capitalisation is the caller's responsibility. Groups appear in the + // order their first member is declared, which can interact with + // `sort_commands` (sorting may change which member of each group comes + // first, hence which group is listed first). + group string + // examples lists invocations rendered under `Examples:` by `--help`. + // Each entry is one line; a leading `$` is conventional but not required. + examples []string + // learn_more is rendered under the `Learn more:` section by `--help`. + // Newlines split the block into separate lines. + learn_more string + pre_execute FnCommandCallback = unsafe { nil } + execute FnCommandCallback = unsafe { nil } + post_execute FnCommandCallback = unsafe { nil } + disable_flags bool + sort_flags bool + sort_commands bool + parent &Command = unsafe { nil } + commands []Command + flags []Flag + required_args int + args []string + posix_mode bool + defaults struct { pub: help Defaults = true man Defaults = true @@ -63,6 +77,9 @@ pub fn (cmd &Command) str() string { res << ' version: "${cmd.version}"' res << ' description: "${cmd.description}"' res << ' man_description: "${cmd.man_description}"' + res << ' group: "${cmd.group}"' + res << ' examples: ${cmd.examples}' + res << ' learn_more: "${cmd.learn_more}"' res << ' disable_flags: ${cmd.disable_flags}' res << ' sort_flags: ${cmd.sort_flags}' res << ' sort_commands: ${cmd.sort_commands}' diff --git a/vlib/cli/help.v b/vlib/cli/help.v index 9a1baa5ef2ecf0..ad6aefee7a537f 100644 --- a/vlib/cli/help.v +++ b/vlib/cli/help.v @@ -43,6 +43,18 @@ pub fn print_help_for_command(cmd Command) ! { } // help_message returns a generated help message as a `string` for the `Command`. +// +// The output is composed of (each section is omitted when empty): +// +// 1. `Usage:` line +// 2. The description, if any +// 3. `Flags:` — flags defined on this command +// 4. `Inherited flags:` — flags propagated from any ancestor via `Flag.global` +// 5. One section per sub-command group; the group name is rendered as the +// user wrote it followed by `:`. Sub-commands without a group go under +// the default `Commands:` section. +// 6. `Examples:` — `Command.examples` entries, one per line +// 7. `Learn more:` — the multi-line `Command.learn_more` field pub fn (cmd &Command) help_message() string { mut help := '' help += 'Usage: ${cmd.full_name()}' @@ -63,6 +75,43 @@ pub fn (cmd &Command) help_message() string { if cmd.description != '' { help += '\n${cmd.description}\n' } + abbrev_len, name_len := cmd.help_columns() + local_flags, inherited_flags := cmd.partition_flags() + if local_flags.len > 0 { + help += render_flag_section('Flags', local_flags, abbrev_len, name_len, cmd.posix_mode) + } + if inherited_flags.len > 0 { + help += render_flag_section('Inherited flags', inherited_flags, abbrev_len, name_len, + cmd.posix_mode) + } + if cmd.commands.len > 0 { + groups, order := group_commands(cmd.commands) + for idx, key in order { + title := if key == '' { 'Commands' } else { key } + help += render_command_section(title, groups[idx], name_len) + } + } + if cmd.examples.len > 0 { + help += '\nExamples:\n' + base_indent := ' '.repeat(base_indent_len) + for line in cmd.examples { + help += '${base_indent}${line}\n' + } + } + if cmd.learn_more != '' { + help += '\nLearn more:\n' + base_indent := ' '.repeat(base_indent_len) + for line in cmd.learn_more.split_into_lines() { + help += '${base_indent}${line}\n' + } + } + return help +} + +// help_columns computes the abbreviation column width and the name column +// width used by `Flags:`, `Inherited Flags:` and command sections so all +// rows align vertically. +fn (cmd &Command) help_columns() (int, int) { mut abbrev_len := 0 mut name_len := min_description_indent_len if cmd.posix_mode { @@ -73,9 +122,6 @@ pub fn (cmd &Command) help_message() string { name_len = max(name_len, abbrev_len + flag.name.len + spacing + 2) // + 2 for '--' in front } - for command in cmd.commands { - name_len = max(name_len, command.display_name().len + spacing) - } } else { for flag in cmd.flags { if flag.abbrev != '' { @@ -84,44 +130,99 @@ pub fn (cmd &Command) help_message() string { name_len = max(name_len, abbrev_len + flag.name.len + spacing + 1) // + 1 for '-' in front } - for command in cmd.commands { - name_len = max(name_len, command.display_name().len + spacing) + } + for command in cmd.commands { + name_len = max(name_len, command.display_name().len + spacing) + } + return abbrev_len, name_len +} + +// partition_flags splits this command's `flags` into two arrays preserving +// declaration order: locally-defined flags first, then flags inherited from +// any ancestor via `Flag.global`. +fn (cmd &Command) partition_flags() ([]Flag, []Flag) { + inherited_names := cmd.inherited_global_flag_names() + mut local := []Flag{} + mut inherited := []Flag{} + for flag in cmd.flags { + if flag.name in inherited_names { + inherited << flag + } else { + local << flag } } - if cmd.flags.len > 0 { - help += '\nFlags:\n' - for flag in cmd.flags { - mut flag_name := '' - prefix := if cmd.posix_mode { '--' } else { '-' } - if flag.abbrev != '' { - abbrev_indent := - ' '.repeat(abbrev_len - flag.abbrev.len - 1) // - 1 for '-' in front - flag_name = '-${flag.abbrev}${abbrev_indent}${prefix}${flag.name}' - } else { - abbrev_indent := ' '.repeat(abbrev_len) - flag_name = '${abbrev_indent}${prefix}${flag.name}' - } - mut required := '' - if flag.required { - required = ' (required)' + return local, inherited +} + +// inherited_global_flag_names returns the names of flags that any ancestor +// declared with `global = true`. A flag is "inherited" only when an ancestor +// declared it global, never when this command itself defines it. +fn (cmd &Command) inherited_global_flag_names() []string { + mut names := []string{} + mut walker := cmd.parent + for unsafe { walker != nil } { + for flag in walker.flags { + if flag.global && flag.name !in names { + names << flag.name } - base_indent := ' '.repeat(base_indent_len) - description_indent := ' '.repeat(name_len - flag_name.len) - help += '${base_indent}${flag_name}${description_indent}' + - pretty_description(flag.description + required, base_indent_len + name_len) + '\n' } + walker = walker.parent } - if cmd.commands.len > 0 { - help += '\nCommands:\n' - for command in cmd.commands { - command_name := command.display_name() - base_indent := ' '.repeat(base_indent_len) - description_indent := ' '.repeat(name_len - command_name.len) - help += '${base_indent}${command_name}${description_indent}' + - pretty_description(command.description, base_indent_len + name_len) + '\n' + return names +} + +// group_commands buckets `commands` by their `group` field, preserving the +// declaration order of both groups and members. +fn group_commands(commands []Command) ([][]Command, []string) { + mut order := []string{} + mut groups := [][]Command{} + for sub in commands { + key := sub.group + idx := order.index(key) + if idx == -1 { + order << key + groups << [sub] + } else { + groups[idx] << sub } } - return help + return groups, order +} + +fn render_flag_section(title string, flags []Flag, abbrev_len int, name_len int, posix_mode bool) string { + mut out := '\n${title}:\n' + prefix := if posix_mode { '--' } else { '-' } + base_indent := ' '.repeat(base_indent_len) + for flag in flags { + mut flag_name := '' + if flag.abbrev != '' { + abbrev_indent := ' '.repeat(abbrev_len - flag.abbrev.len - 1) // - 1 for '-' in front + flag_name = '-${flag.abbrev}${abbrev_indent}${prefix}${flag.name}' + } else { + abbrev_indent := ' '.repeat(abbrev_len) + flag_name = '${abbrev_indent}${prefix}${flag.name}' + } + mut required := '' + if flag.required { + required = ' (required)' + } + description_indent := ' '.repeat(name_len - flag_name.len) + out += '${base_indent}${flag_name}${description_indent}' + + pretty_description(flag.description + required, base_indent_len + name_len) + '\n' + } + return out +} + +fn render_command_section(title string, commands []Command, name_len int) string { + mut out := '\n${title}:\n' + base_indent := ' '.repeat(base_indent_len) + for command in commands { + command_name := command.display_name() + description_indent := ' '.repeat(name_len - command_name.len) + out += '${base_indent}${command_name}${description_indent}' + + pretty_description(command.description, base_indent_len + name_len) + '\n' + } + return out } // pretty_description resizes description text depending on terminal width. diff --git a/vlib/cli/help_test.v b/vlib/cli/help_test.v index 96876fdfd2f3ae..00e7f571095459 100644 --- a/vlib/cli/help_test.v +++ b/vlib/cli/help_test.v @@ -63,3 +63,138 @@ Commands: sub2 another subcommand ' } + +fn test_help_message_groups_commands_by_group_field() { + cmd := Command{ + name: 'app' + description: 'app' + commands: [ + Command{ + name: 'auth' + description: 'Authenticate' + group: 'Core commands' + }, + Command{ + name: 'repo' + description: 'Work with repositories' + group: 'Core commands' + }, + Command{ + name: 'alias' + description: 'Define shortcuts' + group: 'General commands' + }, + Command{ + name: 'misc' + description: 'Other things' + }, + ] + } + assert cmd.help_message() == r'Usage: app [commands] + +app + +Core commands: + auth Authenticate + repo Work with repositories + +General commands: + alias Define shortcuts + +Commands: + misc Other things +' +} + +fn test_help_message_renders_group_title_verbatim() { + // Group string is rendered as-is; capitalisation is the caller's + // choice. Non-ASCII first characters must work without mojibake. + cmd := Command{ + name: 'app' + commands: [ + Command{ + name: 'edit' + description: 'Edit something' + group: 'éditeurs' + }, + ] + } + assert cmd.help_message() == r'Usage: app [commands] + +éditeurs: + edit Edit something +' +} + +fn test_help_message_separates_inherited_flags_from_locals() { + mut root := Command{ + name: 'app' + flags: [ + Flag{ + flag: .string + name: 'config' + abbrev: 'c' + description: 'Path to config' + global: true + }, + ] + commands: [ + Command{ + name: 'run' + description: 'Run something' + flags: [ + Flag{ + flag: .bool + name: 'verbose' + abbrev: 'v' + description: 'Verbose output' + }, + ] + }, + ] + } + root.setup() + mut sub := root.commands[0] + sub.parent = unsafe { &root } + // Mirror what `parse_commands` does when a child is dispatched. + sub.flags << root.flags[0] + assert sub.help_message() == r'Usage: app run [flags] + +Run something + +Flags: + -v -verbose Verbose output + +Inherited flags: + -c -config Path to config +' +} + +fn test_help_message_renders_examples_and_learn_more() { + cmd := Command{ + name: 'app' + description: 'app' + examples: ['\$ app run', '\$ app help run'] + learn_more: 'Use `app help ` for details about a command.\nDocumentation lives at https://example.test' + } + assert cmd.help_message() == r'Usage: app + +app + +Examples: + $ app run + $ app help run + +Learn more: + Use `app help ` for details about a command. + Documentation lives at https://example.test +' +} + +fn test_help_message_omits_empty_optional_sections() { + cmd := Command{ + name: 'app' + } + // No description, no flags, no commands, no examples, no learn_more. + assert cmd.help_message() == 'Usage: app\n' +}