diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f45a1a19aa..11de4a757f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,7 +53,7 @@ jobs: run: make v3diff - if: success() && matrix.go == 'stable' && matrix.os == 'ubuntu-24.04' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true diff --git a/autocomplete/bash_autocomplete b/autocomplete/bash_autocomplete index d63937d971..fd08201e47 100755 --- a/autocomplete/bash_autocomplete +++ b/autocomplete/bash_autocomplete @@ -1,32 +1,105 @@ -#!/bin/bash - # This is a shell completion script auto-generated by https://github.com/urfave/cli for bash. -# Macs have bash3 for which the bash-completion package doesn't include -# _init_completion. This is a minimal version of that function. +# macOS still ships Bash 3, where bash-completion may not provide +# _init_completion. This is a minimal compatible fallback. __%[1]s_init_completion() { COMPREPLY=() - _get_comp_words_by_ref "$@" cur prev words cword + if declare -F _comp_initialize >/dev/null 2>&1; then + _comp_initialize "$@" + else + _get_comp_words_by_ref "$@" cur prev words cword + fi +} + +__%[1]s_build_completion_request() { + local -a words_before_cursor=("${COMP_WORDS[@]:0:${COMP_CWORD}}") + local current_word="${COMP_WORDS[COMP_CWORD]}" + + if [[ "${current_word}" == "-"* ]]; then + printf '%%s %%s --generate-shell-completion' "${words_before_cursor[*]}" "${current_word}" + else + printf '%%s --generate-shell-completion' "${words_before_cursor[*]}" + fi +} + +# Keep Bash 3 compatibility: associative arrays require Bash 4+, so +# descriptions are looked up via parallel indexed arrays. +__%[1]s_find_description_for() { + local candidate="$1" + local i + + for i in "${!__cli_completion_tokens[@]}"; do + if [[ "${__cli_completion_tokens[i]}" == "${candidate}" ]]; then + printf '%%s' "${__cli_completion_descriptions[i]}" + return 0 + fi + done + + return 1 } __%[1]s_bash_autocomplete() { - if [[ "${COMP_WORDS[0]}" != "source" ]]; then - local cur opts base words + local words=("${COMP_WORDS[@]}") + + if [[ "${words[0]}" != "source" ]]; then + local cur opts + local cword="${COMP_CWORD}" + local request_comp + COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - if declare -F _init_completion >/dev/null 2>&1; then - _init_completion -n "=:" || return - else - __%[1]s_init_completion -n "=:" || return - fi - words=("${words[@]:0:$cword}") - if [[ "$cur" == "-"* ]]; then - requestComp="${words[*]} ${cur} --generate-shell-completion" + cur="${words[$cword]}" + + __%[1]s_init_completion -n "=:" || return + + request_comp="$(__%[1]s_build_completion_request)" + opts=$(eval "${request_comp}" 2>/dev/null) + + # Completion output lines use "token:description" format. + # Keep token/description in parallel arrays for Bash 3 compatibility. + __cli_completion_tokens=() + __cli_completion_descriptions=() + + local line + local longest=0 + while IFS=$'\n' read -r line; do + local token="${line}" + local description="" + + if [[ "${line}" == *:* ]]; then + token="${line%%:*}" + description="${line#*:}" + fi + + if [[ -z "${token}" ]]; then + continue + fi + + __cli_completion_tokens+=("${token}") + __cli_completion_descriptions+=("${description}") + (( ${#token} > longest )) && longest=${#token} + done <<< "${opts}" + + local matches=( $(compgen -W "${__cli_completion_tokens[*]}" -- "${cur}") ) + + # COMP_TYPE=63 means Bash is listing matches (usually on second TAB). + if [[ "${COMP_TYPE:-}" == "63" && ${#matches[@]} -gt 0 ]]; then + local listed=() + local candidate + for candidate in "${matches[@]}"; do + local desc="$(__%[1]s_find_description_for "${candidate}")" + + if [[ -n "${desc}" ]]; then + local padded="$(printf '%%-*s' "${longest}" "${candidate}")" + listed+=("${padded} -- ${desc}") + else + listed+=("${candidate}") + fi + done + COMPREPLY=("${listed[@]}") else - requestComp="${words[*]} --generate-shell-completion" + COMPREPLY=("${matches[@]}") fi - opts=$(eval "${requestComp}" 2>/dev/null) - COMPREPLY=($(compgen -W "${opts}" -- ${cur})) + return 0 fi } diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 7aa7d0a8ba..6714f75e0b 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -1,6 +1,6 @@ # This is a shell completion script auto-generated by https://github.com/urfave/cli for fish. -function __%[1]_perform_completion +function __%[1]s_perform_completion # Extract all args except the last one set -l args (commandline -opc) # Extract the last arg (partial input) @@ -18,18 +18,18 @@ function __%[1]_perform_completion end for line in $results - if not string match -q -- "%[1]*" $line + if not string match -q -- "%[1]s*" $line set -l parts (string split -m 1 ":" -- "$line") if test (count $parts) -eq 2 - printf "%s\t%s\n" "$parts[1]" "$parts[2]" + printf "%%s\t%%s\n" "$parts[1]" "$parts[2]" else - printf "%s\n" "$line" + printf "%%s\n" "$line" end end end end -# Clear existing completions for %[1] -complete -c %[1] -e +# Clear existing completions for %[1]s +complete -c %[1]s -e # Register completion function -complete -c %[1] -f -a '(__%[1]_perform_completion)' \ No newline at end of file +complete -c %[1]s -f -a '(__%[1]s_perform_completion)' \ No newline at end of file diff --git a/autocomplete/powershell_autocomplete.ps1 b/autocomplete/powershell_autocomplete.ps1 index 6e0c422e25..fee6d0c7d2 100644 --- a/autocomplete/powershell_autocomplete.ps1 +++ b/autocomplete/powershell_autocomplete.ps1 @@ -1,9 +1,16 @@ $fn = $($MyInvocation.MyCommand.Name) $name = $fn -replace "(.*)\.ps1$", '$1' Register-ArgumentCompleter -Native -CommandName $name -ScriptBlock { - param($commandName, $wordToComplete, $cursorPosition) - $other = "$wordToComplete --generate-shell-completion" - Invoke-Expression $other | ForEach-Object { + param($commandName, $wordToComplete, $cursorPosition) + $other = "$wordToComplete --generate-shell-completion" + Invoke-Expression $other | ForEach-Object { + $parts = $_.Split(':', 2) + if ($parts.Count -eq 2) { + $completion = $parts[0].Trim() + $description = $parts[1].Trim() + [System.Management.Automation.CompletionResult]::new($completion, $completion, 'ParameterValue', $description) + } else { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } - } + } + } +} \ No newline at end of file diff --git a/command.go b/command.go index 2a46b2eab1..d1a9d0575d 100644 --- a/command.go +++ b/command.go @@ -408,6 +408,12 @@ func (cmd *Command) checkRequiredFlag(f Flag) (bool, string) { } func (cmd *Command) checkAllRequiredFlags() requiredFlagsErr { + // The help and completion commands are allowed to run without + // enforcement of required flags, since they do not invoke user + // actions that depend on those flag values. + if cmd.Name == helpName || cmd.isCompletionCommand { + return nil + } for pCmd := cmd; pCmd != nil; pCmd = pCmd.parent { if err := pCmd.checkRequiredFlags(); err != nil { return err diff --git a/command_parse.go b/command_parse.go index 92c58eeaac..2b9a481df2 100644 --- a/command_parse.go +++ b/command_parse.go @@ -86,6 +86,11 @@ func (cmd *Command) parseFlags(args Args) (Args, error) { // stop parsing once we see a "--" if firstArg == "--" { + // In shell completion mode, preserve "--" so that completion can detect + // when the user is completing "--" itself vs. completing after "--" + if cmd.Root().shellCompletion { + posArgs = append(posArgs, firstArg) + } posArgs = append(posArgs, rargs[1:]...) return &stringSliceArgs{posArgs}, nil } @@ -166,6 +171,12 @@ func (cmd *Command) parseFlags(args Args) (Args, error) { // not a bool flag so need to get the next arg if flagVal == "" && !valFromEqual { if len(rargs) == 1 { + // In shell completion mode, preserve the flag so that DefaultCompleteWithFlags can use it + // as lastArg and offer suggestions for it. + if cmd.Root().shellCompletion { + posArgs = append(posArgs, rargs...) + return &stringSliceArgs{posArgs}, nil + } return &stringSliceArgs{posArgs}, fmt.Errorf("%s%s", argumentNotProvidedErrMsg, firstArg) } flagVal = rargs[1] @@ -182,6 +193,12 @@ func (cmd *Command) parseFlags(args Args) (Args, error) { // no flag lookup found and short handling is disabled if !shortOptionHandling { + // In shell completion mode, preserve the partial flag so that DefaultCompleteWithFlags can use it + // as lastArg and offer suggestions that match the prefix. + if cmd.Root().shellCompletion { + posArgs = append(posArgs, rargs...) + return &stringSliceArgs{posArgs}, nil + } return &stringSliceArgs{posArgs}, fmt.Errorf("%s%s", providedButNotDefinedErrMsg, flagName) } diff --git a/command_run.go b/command_run.go index 676a14c676..269dd85f2e 100644 --- a/command_run.go +++ b/command_run.go @@ -141,8 +141,8 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context var rargs Args = &stringSliceArgs{v: osArgs} var args Args = &stringSliceArgs{rargs.Tail()} - if cmd.isCompletionCommand || cmd.Name == helpName { - tracef("special command detected, skipping pre-parse (cmd=%[1]q)", cmd.Name) + if cmd.isCompletionCommand { + tracef("completion command detected, skipping pre-parse (cmd=%[1]q)", cmd.Name) cmd.parsedArgs = args return ctx, cmd.Action(ctx, cmd) } @@ -213,6 +213,7 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context } for _, flag := range cmd.allFlags() { + cmd.setMultiValueParsingConfig(flag) isSet := flag.IsSet() if err := flag.PostParse(); err != nil { return ctx, err diff --git a/command_test.go b/command_test.go index dafee0bee6..72fa6a6628 100644 --- a/command_test.go +++ b/command_test.go @@ -599,8 +599,8 @@ func TestCommand_Run_CustomShellCompleteAcceptsMalformedFlags(t *testing.T) { testArgs *stringSliceArgs expectedOut string }{ - {testArgs: &stringSliceArgs{v: []string{"--undefined"}}, expectedOut: "found 0 args"}, - {testArgs: &stringSliceArgs{v: []string{"--number"}}, expectedOut: "found 0 args"}, + {testArgs: &stringSliceArgs{v: []string{"--undefined"}}, expectedOut: "found 1 args"}, + {testArgs: &stringSliceArgs{v: []string{"--number"}}, expectedOut: "found 1 args"}, {testArgs: &stringSliceArgs{v: []string{"--number", "forty-two"}}, expectedOut: "found 0 args"}, {testArgs: &stringSliceArgs{v: []string{"--number", "42"}}, expectedOut: "found 0 args"}, {testArgs: &stringSliceArgs{v: []string{"--number", "42", "newArg"}}, expectedOut: "found 1 args"}, @@ -4590,6 +4590,24 @@ func TestCommandSliceFlagSeparator(t *testing.T) { r.Equal([]string{"ff", "dd", "gg", "t,u"}, cmd.Value("foo")) } +func TestCommandSliceFlagSeparatorFromEnvVar(t *testing.T) { + t.Setenv("APP_FOO", "0 1 2") + + cmd := &Command{ + SliceFlagSeparator: " ", + Flags: []Flag{ + &StringSliceFlag{ + Name: "foo", + Sources: EnvVars("APP_FOO"), + }, + }, + } + + r := require.New(t) + r.NoError(cmd.Run(buildTestContext(t), []string{"app"})) + r.Equal([]string{"0", "1", "2"}, cmd.Value("foo")) +} + func TestCommandMapKeyValueFlagSeparator(t *testing.T) { cmd := &Command{ MapFlagKeyValueSeparator: ":", diff --git a/completion_test.go b/completion_test.go index 4f921c3f75..889bfdcf59 100644 --- a/completion_test.go +++ b/completion_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -63,6 +64,57 @@ func TestCompletionShell(t *testing.T) { } } +func TestCompletionBashNoShebang(t *testing.T) { + // Regression test for https://github.com/urfave/cli/issues/2259 + // Bash completion scripts are sourced, not executed, so they must not + // start with a `#!` shebang (flagged by Debian lintian as + // `bash-completion-with-hashbang`). + + cmd := &Command{ + EnableShellCompletion: true, + } + + r := require.New(t) + + bashRender := shellCompletions["bash"] + r.NotNil(bashRender, "bash completion renderer should exist") + + output, err := bashRender(cmd, "myapp") + r.NoError(err) + r.NotEmpty(output, "bash completion output should not be empty") + r.False(strings.HasPrefix(output, "#!"), "bash completion should not start with a shebang") +} + +func TestCompletionFishFormat(t *testing.T) { + // Regression test for https://github.com/urfave/cli/issues/2285 + // Fish completion was broken due to incorrect format specifiers + + cmd := &Command{ + Name: "myapp", + EnableShellCompletion: true, + } + + r := require.New(t) + + // Test the fish shell completion renderer directly + fishRender := shellCompletions["fish"] + r.NotNil(fishRender, "fish completion renderer should exist") + + output, err := fishRender(cmd, "myapp") + r.NoError(err) + + // Verify the function name is correctly formatted + r.Contains(output, "function __myapp_perform_completion", "function name should contain app name") + + // Verify no format errors (like %! or (string=) which indicate broken fmt.Sprintf) + r.NotContains(output, "%!", "output should not contain format errors") + r.NotContains(output, "(string=", "output should not contain invalid fish syntax") + + // Verify the complete commands reference the app correctly + r.Contains(output, "complete -c myapp", "complete command should reference app name") + r.Contains(output, "(__myapp_perform_completion)", "completion function should be registered") +} + func TestCompletionSubcommand(t *testing.T) { tests := []struct { name string @@ -91,14 +143,13 @@ func TestCompletionSubcommand(t *testing.T) { }, }, { - name: "subcommand flag no completion", + name: "subcommand double dash shows long flags", args: []string{"foo", "bar", "--", completionFlag}, - contains: "l1", - msg: "Expected output to contain shell name %[1]q", + contains: "--l1", + msg: "Expected output to contain flag %[1]q", msgArgs: []any{ - "l1", + "--l1", }, - notContains: true, }, { name: "sub sub command general completion", @@ -120,14 +171,13 @@ func TestCompletionSubcommand(t *testing.T) { }, }, { - name: "sub sub command no completion", + name: "sub sub command double dash shows flags", args: []string{"foo", "bar", "xyz", "--", completionFlag}, - contains: "-g", + contains: "--help", msg: "Expected output to contain flag %[1]q", msgArgs: []any{ - "-g", + "--help", }, - notContains: true, }, { name: "sub sub command no completion extra args", @@ -139,6 +189,24 @@ func TestCompletionSubcommand(t *testing.T) { }, notContains: true, }, + { + name: "subcommand partial double dash flag completion", + args: []string{"foo", "bar", "--l", completionFlag}, + contains: "--l1", + msg: "Expected output to contain flag %[1]q", + msgArgs: []any{ + "--l1", + }, + }, + { + name: "sub sub command partial double dash flag completion", + args: []string{"foo", "bar", "xyz", "--he", completionFlag}, + contains: "--help", + msg: "Expected output to contain flag %[1]q", + msgArgs: []any{ + "--help", + }, + }, } for _, test := range tests { diff --git a/docs/v3/examples/completions/customizations.md b/docs/v3/examples/completions/customizations.md index 183f3d8250..96f3565359 100644 --- a/docs/v3/examples/completions/customizations.md +++ b/docs/v3/examples/completions/customizations.md @@ -112,7 +112,7 @@ The default shell completion flag (`--generate-shell-completion`) is defined as ```go package main diff --git a/docs/v3/examples/completions/shell-completions.md b/docs/v3/examples/completions/shell-completions.md index b355a6320f..8c4137e598 100644 --- a/docs/v3/examples/completions/shell-completions.md +++ b/docs/v3/examples/completions/shell-completions.md @@ -6,7 +6,7 @@ search: --- The urfave/cli v3 library supports programmable completion for apps utilizing its framework. This means -that the completion is generated dynamically at runtime by invokiong the app itself with a special hidden +that the completion is generated dynamically at runtime by invoking the app itself with a special hidden flag. The urfave/cli searches for this flag and activates a different flow for command paths than regular flow The following shells are supported diff --git a/errors.go b/errors.go index f365a57990..ffd6471f23 100644 --- a/errors.go +++ b/errors.go @@ -150,10 +150,12 @@ func HandleExitCoder(err error) { } if exitErr, ok := err.(ExitCoder); ok { - if _, ok := exitErr.(ErrorFormatter); ok { - _, _ = fmt.Fprintf(ErrWriter, "%+v\n", err) - } else { - _, _ = fmt.Fprintln(ErrWriter, err) + if msg := err.Error(); msg != "" { + if _, ok := exitErr.(ErrorFormatter); ok { + _, _ = fmt.Fprintf(ErrWriter, "%+v\n", err) + } else { + _, _ = fmt.Fprintln(ErrWriter, err) + } } OsExiter(exitErr.ExitCode()) return diff --git a/errors_test.go b/errors_test.go index 35aaab54d4..e995b6f250 100644 --- a/errors_test.go +++ b/errors_test.go @@ -232,3 +232,29 @@ func TestErrRequiredFlags_Error(t *testing.T) { expectedMsg = "Required flag \"flag1\" not set" assert.Equal(t, expectedMsg, err.Error()) } + +func TestHandleExitCoder_ExitCoderEmptyMessage(t *testing.T) { + exitCode := 0 + called := false + + OsExiter = func(rc int) { + if !called { + exitCode = rc + called = true + } + } + + defer func() { OsExiter = fakeOsExiter }() + + // Capture stderr output + savedErrWriter := ErrWriter + var errBuf bytes.Buffer + ErrWriter = &errBuf + defer func() { ErrWriter = savedErrWriter }() + + HandleExitCoder(Exit("", 42)) + + assert.Equal(t, 42, exitCode) + assert.True(t, called) + assert.Empty(t, errBuf.String(), "expected no output to stderr for empty exit message") +} diff --git a/examples_test.go b/examples_test.go index afda009ceb..49b63301d4 100644 --- a/examples_test.go +++ b/examples_test.go @@ -182,13 +182,16 @@ func ExampleCommand_Run_commandHelp() { // greet describeit - use it to see a description // // USAGE: - // greet describeit [arguments...] + // greet describeit [options] [arguments...] // // DESCRIPTION: // This is how we describe describeit the function // // OPTIONS: // --help, -h show help + // + // GLOBAL OPTIONS: + // --name string a name to say (default: "pat") } func ExampleCommand_Run_noAction() { @@ -258,14 +261,13 @@ func ExampleCommand_Run_shellComplete_bash_withShortFlag() { } // Simulate a bash environment and command line arguments - os.Setenv("SHELL", "bash") os.Args = []string{"greet", "-", "--generate-shell-completion"} _ = cmd.Run(context.Background(), os.Args) // Output: // --other // --xyz - // --help + // --help:show help } func ExampleCommand_Run_shellComplete_bash_withLongFlag() { @@ -291,7 +293,6 @@ func ExampleCommand_Run_shellComplete_bash_withLongFlag() { } // Simulate a bash environment and command line arguments - os.Setenv("SHELL", "bash") os.Args = []string{"greet", "--s", "--generate-shell-completion"} _ = cmd.Run(context.Background(), os.Args) @@ -326,7 +327,6 @@ func ExampleCommand_Run_shellComplete_bash_withMultipleLongFlag() { } // Simulate a bash environment and command line arguments - os.Setenv("SHELL", "bash") os.Args = []string{"greet", "--st", "--generate-shell-completion"} _ = cmd.Run(context.Background(), os.Args) @@ -362,14 +362,13 @@ func ExampleCommand_Run_shellComplete_bash() { } // Simulate a bash environment and command line arguments - os.Setenv("SHELL", "bash") os.Args = []string{"greet", "--generate-shell-completion"} _ = cmd.Run(context.Background(), os.Args) // Output: - // describeit - // next - // help + // describeit:use it to see a description + // next:next example + // help:Shows a list of commands or help for one command } func ExampleCommand_Run_shellComplete_zsh() { @@ -400,7 +399,6 @@ func ExampleCommand_Run_shellComplete_zsh() { // Simulate a zsh environment and command line arguments os.Args = []string{"greet", "--generate-shell-completion"} - os.Setenv("SHELL", "/usr/bin/zsh") _ = cmd.Run(context.Background(), os.Args) // Output: @@ -437,7 +435,6 @@ func ExampleCommand_Run_shellComplete_fish() { // Simulate a fish environment and command line arguments os.Args = []string{"greet", "--generate-shell-completion"} - os.Setenv("SHELL", "/usr/bin/fish") _ = cmd.Run(context.Background(), os.Args) // Output: diff --git a/flag_bool_with_inverse.go b/flag_bool_with_inverse.go index 272dd98fec..8666e2d3d5 100644 --- a/flag_bool_with_inverse.go +++ b/flag_bool_with_inverse.go @@ -167,6 +167,9 @@ func (bif *BoolWithInverseFlag) IsVisible() bool { // // Example for BoolFlag{Name: "env"} // --[no-]env (default: false) +// +// Example for BoolFlag{Name: "env", Aliases: []string{"e"}} +// --[no-]env, -e (default: false) func (bif *BoolWithInverseFlag) String() string { out := FlagStringer(bif) @@ -179,7 +182,29 @@ func (bif *BoolWithInverseFlag) String() string { prefix = "-" } - return fmt.Sprintf("%s[%s]%s%s", prefix, bif.inversePrefix(), bif.Name, out[i:]) + // Guard against a FlagStringer that returns a string without a tab (e.g. + // a custom stringer or the default stringer when the flag does not + // implement DocGenerationFlag). In that case treat the entire output as + // the tab-delimited suffix so the slice never goes out of bounds. + if i < 0 { + i = 0 + } + + var aliasParts []string + for _, alias := range bif.Aliases { + aPrefix := "--" + if len(alias) == 1 { + aPrefix = "-" + } + aliasParts = append(aliasParts, aPrefix+alias) + } + + names := fmt.Sprintf("%s[%s]%s", prefix, bif.inversePrefix(), bif.Name) + if len(aliasParts) > 0 { + names = names + ", " + strings.Join(aliasParts, ", ") + } + + return fmt.Sprintf("%s%s", names, out[i:]) } // IsBoolFlag returns whether the flag doesnt need to accept args diff --git a/flag_bool_with_inverse_test.go b/flag_bool_with_inverse_test.go index 981494586b..083a072e27 100644 --- a/flag_bool_with_inverse_test.go +++ b/flag_bool_with_inverse_test.go @@ -345,6 +345,7 @@ func TestBoolWithInverseString(t *testing.T) { required bool usage string inversePrefix string + aliases []string expected string }{ { @@ -397,6 +398,27 @@ func TestBoolWithInverseString(t *testing.T) { required: true, expected: "--[no-]env\t", }, + { + testName: "short alias", + flagName: "color", + required: false, + aliases: []string{"c"}, + expected: "--[no-]color, -c\t(default: false)", + }, + { + testName: "long alias", + flagName: "color", + required: false, + aliases: []string{"colour"}, + expected: "--[no-]color, --colour\t(default: false)", + }, + { + testName: "multiple aliases", + flagName: "color", + required: false, + aliases: []string{"c", "colour"}, + expected: "--[no-]color, -c, --colour\t(default: false)", + }, } for _, tc := range tcs { @@ -406,6 +428,7 @@ func TestBoolWithInverseString(t *testing.T) { Usage: tc.usage, Required: tc.required, InversePrefix: tc.inversePrefix, + Aliases: tc.aliases, } require.Equal(t, tc.expected, flag.String()) @@ -520,3 +543,26 @@ func TestBoolWithInverseFlag_SatisfiesVisibleFlagInterface(t *testing.T) { _ = f.IsVisible() } + +// TestBoolWithInverseFlagStringNoPanicWithNoTabStringer is a regression test for +// https://github.com/urfave/cli/issues/2303. +// BoolWithInverseFlag.String() panicked with "slice bounds out of range [-1:]" +// when the FlagStringer returned a string without a tab character. +func TestBoolWithInverseFlagStringNoPanicWithNoTabStringer(t *testing.T) { + orig := FlagStringer + defer func() { FlagStringer = orig }() + + FlagStringer = func(f Flag) string { + return "no tab here" + } + + flag := &BoolWithInverseFlag{ + Name: "verbose", + } + + // Must not panic. + got := flag.String() + if !strings.Contains(got, "verbose") { + t.Errorf("expected String() to contain the flag name, got %q", got) + } +} diff --git a/godoc-current.txt b/godoc-current.txt index a31fb21089..1e163a0cb9 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -414,6 +414,9 @@ func (bif *BoolWithInverseFlag) String() string Example for BoolFlag{Name: "env"} --[no-]env (default: false) + Example for BoolFlag{Name: "env", Aliases: []string{"e"}} --[no-]env, + -e (default: false) + func (bif *BoolWithInverseFlag) TakesValue() bool func (bif *BoolWithInverseFlag) TypeName() string diff --git a/help.go b/help.go index 0f7f629caf..1fba6edf0c 100644 --- a/help.go +++ b/help.go @@ -126,16 +126,8 @@ func helpCommandAction(ctx context.Context, cmd *Command) error { // Case 3, 5 if len(cmd.VisibleCommands()) == 0 { - - tmpl := cmd.CustomHelpTemplate - if tmpl == "" { - tmpl = CommandHelpTemplate - } - tracef("running HelpPrinter with command %[1]q", cmd.Name) - HelpPrinter(cmd.Root().Writer, tmpl, cmd) - - return nil + return ShowCommandHelp(ctx, cmd.parent, cmd.Name) } tracef("running ShowSubcommandHelp") @@ -184,12 +176,11 @@ func DefaultRootCommandComplete(ctx context.Context, cmd *Command) { var DefaultAppComplete = DefaultRootCommandComplete func printCommandSuggestions(commands []*Command, writer io.Writer) { - shell := os.Getenv("SHELL") for _, command := range commands { if command.Hidden { continue } - if (strings.HasSuffix(shell, "zsh") || strings.HasSuffix(shell, "fish")) && len(command.Usage) > 0 { + if len(command.Usage) > 0 { _, _ = fmt.Fprintf(writer, "%s:%s\n", command.Name, command.Usage) } else { _, _ = fmt.Fprintf(writer, "%s\n", command.Name) @@ -239,8 +230,7 @@ func printFlagSuggestions(lastArg string, flags []Flag, writer io.Writer) { // match if last argument matches this flag and it is not repeated if strings.HasPrefix(name, cur) && cur != name /* && !cliArgContains(name, os.Args)*/ { flagCompletion := fmt.Sprintf("%s%s", strings.Repeat("-", count), name) - shell := os.Getenv("SHELL") - if usage != "" && (strings.HasSuffix(shell, "zsh") || strings.HasSuffix(shell, "fish")) { + if usage != "" { flagCompletion = fmt.Sprintf("%s:%s", flagCompletion, usage) } fmt.Fprintln(writer, flagCompletion) @@ -266,11 +256,6 @@ func DefaultCompleteWithFlags(ctx context.Context, cmd *Command) { lastArg = args[argsLen-1] } - if lastArg == "--" { - tracef("No completions due to termination") - return - } - if lastArg == completionFlag { lastArg = "" } @@ -303,7 +288,7 @@ func DefaultShowCommandHelp(ctx context.Context, cmd *Command, commandName strin tmpl := subCmd.CustomHelpTemplate if tmpl == "" { - if len(subCmd.Commands) == 0 { + if len(subCmd.VisibleCommands()) == 0 { tracef("using CommandHelpTemplate") tmpl = CommandHelpTemplate } else { @@ -495,10 +480,12 @@ func checkShellCompleteFlag(c *Command, arguments []string) (bool, []string) { return false, arguments } - // If arguments include "--", shell completion is disabled - // because after "--" only positional arguments are accepted. + // If arguments include "--" before the token being completed, shell completion + // is disabled because after "--" only positional arguments are accepted. // https://unix.stackexchange.com/a/11382 - if slices.Contains(arguments, "--") { + // Note: The token being completed is at position pos-1 (immediately before completionFlag). + // We only check arguments before that position, so completing "--" itself still works. + if pos >= 1 && slices.Contains(arguments[:pos-1], "--") { return false, arguments[:pos] } diff --git a/help_test.go b/help_test.go index e5c72cad68..d3c831371d 100644 --- a/help_test.go +++ b/help_test.go @@ -164,6 +164,35 @@ GLOBAL OPTIONS: "expected output to include usage text") } +// Test_HelpCommand_ParsesFlags is a regression test for #2271: flags +// declared on a user-supplied command named "help" must still be parsed. +// Previously in v3.6.2 flag pre-parsing was skipped for any command named +// "help", causing such flags to be silently dropped. +func Test_HelpCommand_ParsesFlags(t *testing.T) { + var parsed string + + cmd := &Command{ + Name: "app", + Commands: []*Command{ + { + Name: "help", + Flags: []Flag{ + &StringFlag{Name: "topic", Aliases: []string{"t"}}, + }, + Action: func(_ context.Context, c *Command) error { + parsed = c.String("topic") + return nil + }, + }, + }, + } + + err := cmd.Run(buildTestContext(t), []string{"app", "help", "--topic", "foo"}) + require.NoError(t, err) + assert.Equal(t, "foo", parsed, + "expected --topic flag on help subcommand to be parsed") +} + func Test_Help_Custom_Flags(t *testing.T) { oldFlag := HelpFlag defer func() { @@ -571,7 +600,7 @@ func TestShowCommandHelp_HelpPrinterCustom(t *testing.T) { fmt.Fprint(w, "yo") }, arguments: []string{"my-app", "help", "my-command"}, - wantTemplate: SubcommandHelpTemplate, + wantTemplate: CommandHelpTemplate, wantOutput: "yo", }, { @@ -1325,7 +1354,7 @@ func TestDefaultCompleteWithFlags(t *testing.T) { expected: "", }, { - name: "flag-suggestion-end-args", + name: "flag-suggestion-double-dash-shows-all-flags", cmd: &Command{ Flags: []Flag{ &BoolFlag{Name: "excitement"}, @@ -1344,7 +1373,7 @@ func TestDefaultCompleteWithFlags(t *testing.T) { }, argv: []string{"cmd", "--e", "--", completionFlag}, env: map[string]string{"SHELL": "bash"}, - expected: "", + expected: "--excitement\n--hat-shape\n", }, { name: "typical-command-suggestion", @@ -1907,6 +1936,15 @@ func Test_checkShellCompleteFlag(t *testing.T) { wantShellCompletion: true, wantArgs: []string{"foo"}, }, + { + name: "double dash is the token being completed", + arguments: []string{"foo", "--", completionFlag}, + cmd: &Command{ + EnableShellCompletion: true, + }, + wantShellCompletion: true, + wantArgs: []string{"foo", "--"}, + }, } for _, tt := range tests { diff --git a/mkdocs-requirements.txt b/mkdocs-requirements.txt index e5848c726a..d7f1b2c694 100644 --- a/mkdocs-requirements.txt +++ b/mkdocs-requirements.txt @@ -1,5 +1,5 @@ mkdocs-git-revision-date-localized-plugin==1.5.1 -mkdocs-material==9.7.5 +mkdocs-material==9.7.6 mkdocs==1.6.1 -mkdocs-redirects==1.2.2 -pygments==2.19.2 +mkdocs-redirects==1.2.3 +pygments==2.20.0 diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index a31fb21089..1e163a0cb9 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -414,6 +414,9 @@ func (bif *BoolWithInverseFlag) String() string Example for BoolFlag{Name: "env"} --[no-]env (default: false) + Example for BoolFlag{Name: "env", Aliases: []string{"e"}} --[no-]env, + -e (default: false) + func (bif *BoolWithInverseFlag) TakesValue() bool func (bif *BoolWithInverseFlag) TypeName() string