diff --git a/autocomplete/bash_autocomplete b/autocomplete/bash_autocomplete deleted file mode 100755 index 7120a0d222..0000000000 --- a/autocomplete/bash_autocomplete +++ /dev/null @@ -1,35 +0,0 @@ -#! /bin/bash - -: ${PROG:=$(basename ${BASH_SOURCE})} - -# Macs have bash3 for which the bash-completion package doesn't include -# _init_completion. This is a minimal version of that function. -_cli_init_completion() { - COMPREPLY=() - _get_comp_words_by_ref "$@" cur prev words cword -} - -_cli_bash_autocomplete() { - if [[ "${COMP_WORDS[0]}" != "source" ]]; then - local cur opts base words - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - if declare -F _init_completion >/dev/null 2>&1; then - _init_completion -n "=:" || return - else - _cli_init_completion -n "=:" || return - fi - words=("${words[@]:0:$cword}") - if [[ "$cur" == "-"* ]]; then - requestComp="${words[*]} ${cur} --generate-shell-completion" - else - requestComp="${words[*]} --generate-shell-completion" - fi - opts=$(eval "${requestComp}" 2>/dev/null) - COMPREPLY=($(compgen -W "${opts}" -- ${cur})) - return 0 - fi -} - -complete -o bashdefault -o default -o nospace -F _cli_bash_autocomplete $PROG -unset PROG diff --git a/autocomplete/powershell_autocomplete.ps1 b/autocomplete/powershell_autocomplete.ps1 deleted file mode 100644 index cbf76942dd..0000000000 --- a/autocomplete/powershell_autocomplete.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -$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 { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } - } \ No newline at end of file diff --git a/autocomplete/zsh_autocomplete b/autocomplete/zsh_autocomplete deleted file mode 100644 index 943cf60b95..0000000000 --- a/autocomplete/zsh_autocomplete +++ /dev/null @@ -1,30 +0,0 @@ -#compdef program -compdef _program program - -# Replace all occurrences of "program" in this file with the actual name of your -# CLI program. We recommend using Find+Replace feature of your editor. Let's say -# your CLI program is called "acme", then replace like so: -# * program => acme -# * _program => _acme - -_program() { - local -a opts - local cur - cur=${words[-1]} - if [[ "$cur" == "-"* ]]; then - opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-shell-completion)}") - else - opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-shell-completion)}") - fi - - if [[ "${opts[1]}" != "" ]]; then - _describe 'values' opts - else - _files - fi -} - -# don't run the completion function when being source-ed or eval-ed -if [ "$funcstack[1]" = "_program" ]; then - _program -fi diff --git a/command.go b/command.go index 3970f1c355..3b7c97ca97 100644 --- a/command.go +++ b/command.go @@ -257,7 +257,7 @@ func (cmd *Command) setupDefaults(osArgs []string) { } if cmd.EnableShellCompletion || cmd.Root().shellCompletion { - completionCommand := buildCompletionCommand() + completionCommand := buildCompletionCommand(osArgs[0]) if cmd.ShellCompletionCommandName != "" { tracef( diff --git a/completion.go b/completion.go index d344fc4d5c..b8d9dd3618 100644 --- a/completion.go +++ b/completion.go @@ -2,49 +2,45 @@ package cli import ( "context" - "embed" "fmt" "sort" ) const ( - completionCommandName = "generate-completion" - completionFlagName = "generate-shell-completion" - completionFlag = "--" + completionFlagName -) - -var ( - //go:embed autocomplete - autoCompleteFS embed.FS + completionCommandName = "completion" - shellCompletions = map[string]renderCompletion{ - "bash": getCompletion("autocomplete/bash_autocomplete"), - "ps": getCompletion("autocomplete/powershell_autocomplete.ps1"), - "zsh": getCompletion("autocomplete/zsh_autocomplete"), - "fish": func(c *Command) (string, error) { - return c.ToFishCompletion() - }, - } + // This flag is supposed to only be used by the completion script itself to generate completions on the fly. + completionFlag = "--generate-shell-completion" ) -type renderCompletion func(*Command) (string, error) - -func getCompletion(s string) renderCompletion { - return func(c *Command) (string, error) { - b, err := autoCompleteFS.ReadFile(s) - return string(b), err - } +var shellCompletions = map[string]renderCompletionFunc{ + "bash": func(cmd *Command, appName string) (string, error) { + return genBashCompletion(appName), nil + }, + "zsh": func(cmd *Command, appName string) (string, error) { + return genZshCompletion(appName), nil + }, + "fish": func(c *Command, appName string) (string, error) { + return c.ToFishCompletion() + }, + "pwsh": func(cmd *Command, appName string) (string, error) { + return genPwshCompletion(), nil + }, } -func buildCompletionCommand() *Command { +type renderCompletionFunc func(cmd *Command, appName string) (string, error) + +func buildCompletionCommand(appName string) *Command { return &Command{ Name: completionCommandName, Hidden: true, - Action: completionCommandAction, + Action: func(ctx context.Context, cmd *Command) error { + return printShellCompletion(ctx, cmd, appName) + }, } } -func completionCommandAction(ctx context.Context, cmd *Command) error { +func printShellCompletion(_ context.Context, cmd *Command, appName string) error { var shells []string for k := range shellCompletions { shells = append(shells, k) @@ -57,14 +53,103 @@ func completionCommandAction(ctx context.Context, cmd *Command) error { } s := cmd.Args().First() - if rc, ok := shellCompletions[s]; !ok { + renderCompletion, ok := shellCompletions[s] + if !ok { return Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1) - } else if c, err := rc(cmd); err != nil { + } + + completionScript, err := renderCompletion(cmd, appName) + if err != nil { + return Exit(err, 1) + } + + _, err = cmd.Writer.Write([]byte(completionScript)) + if err != nil { return Exit(err, 1) - } else { - if _, err = cmd.Writer.Write([]byte(c)); err != nil { - return Exit(err, 1) - } } + return nil } + +func genBashCompletion(appName string) string { + return fmt.Sprintf(`#!/usr/env/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. +__%[1]s_init_completion() { + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +__%[1]s_bash_autocomplete() { + if [[ "${COMP_WORDS[0]}" != "source" ]]; then + local cur opts base words + 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" + else + requestComp="${words[*]} --generate-shell-completion" + fi + opts=$(eval "${requestComp}" 2>/dev/null) + COMPREPLY=($(compgen -W "${opts}" -- ${cur})) + return 0 + fi +} + +complete -o bashdefault -o default -o nospace -F __%[1]s_bash_autocomplete %[1]s +`, appName) +} + +func genZshCompletion(appName string) string { + return fmt.Sprintf(`#compdef %[1]s +compdef _%[1]s %[1]s + +# This is a shell completion script auto-generated by https://github.com/urfave/cli for zsh. + +_%[1]s() { + local -a opts # Declare a local array + local current + current=${words[-1]} # -1 means "the last element" + if [[ "$current" == "-"* ]]; then + # Current word starts with a hyphen, so complete flags/options + opts=("${(@f)$(${words[@]:0:#words[@]-1} ${current} --generate-shell-completion)}") + else + # Current word does not start with a hyphen, so complete subcommands + opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-shell-completion)}") + fi + + if [[ "${opts[1]}" != "" ]]; then + _describe 'values' opts + else + _files + fi +} + +# Don't run the completion function when being source-ed or eval-ed. +# See https://github.com/urfave/cli/issues/1874 for discussion. +if [ "$funcstack[1]" = "_%[1]s" ]; then + _%[1]s +fi +`, appName) +} + +func genPwshCompletion() string { + return `$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 { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + }` +} diff --git a/completion_test.go b/completion_test.go index e3c951b1b7..3fd1dfce38 100644 --- a/completion_test.go +++ b/completion_test.go @@ -200,7 +200,7 @@ func TestCompletionInvalidShell(t *testing.T) { assert.ErrorContains(t, err, "unknown shell junky-sheell") enableError := true - shellCompletions[unknownShellName] = func(c *Command) (string, error) { + shellCompletions[unknownShellName] = func(c *Command, appName string) (string, error) { if enableError { return "", fmt.Errorf("cant do completion") } diff --git a/examples/simpletask/main.go b/examples/simpletask/main.go new file mode 100644 index 0000000000..a797fa86a3 --- /dev/null +++ b/examples/simpletask/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/urfave/cli/v3" +) + +func main() { + app := &cli.Command{ + Name: "simpletask", + Usage: "a dead simple task manager", + EnableShellCompletion: true, + Action: func(ctx context.Context, command *cli.Command) error { + fmt.Println("decide what to do!") + return nil + }, + Commands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "add a task to the list", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("added task: ", cmd.Args().First()) + return nil + }, + }, + { + Name: "complete", + Aliases: []string{"c"}, + Usage: "complete a task on the list", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("completed task: ", cmd.Args().First()) + return nil + }, + }, + { + Name: "template", + Aliases: []string{"t"}, + Usage: "options for task templates", + Commands: []*cli.Command{ + { + Name: "add", + Usage: "add a new template", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("new task template: ", cmd.Args().First()) + return nil + }, + }, + { + Name: "remove", + Usage: "remove an existing template", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("removed task template: ", cmd.Args().First()) + return nil + }, + }, + }, + }, + }, + } + + err := app.Run(context.Background(), os.Args) + if err != nil { + log.Fatal(err) + } +} diff --git a/help.go b/help.go index 10c2f6e8f0..527f6d7b91 100644 --- a/help.go +++ b/help.go @@ -190,8 +190,9 @@ func cliArgContains(flagName string, args []string) bool { } func printFlagSuggestions(lastArg string, flags []Flag, writer io.Writer) { - cur := strings.TrimPrefix(lastArg, "-") - cur = strings.TrimPrefix(cur, "-") + // Trim the prefix twice to handle both "-short" and "--long" flags. + currentArg := strings.TrimPrefix(lastArg, "-") + currentArg = strings.TrimPrefix(currentArg, "-") for _, flag := range flags { if bflag, ok := flag.(*BoolFlag); ok && bflag.Hidden { continue @@ -214,7 +215,7 @@ func printFlagSuggestions(lastArg string, flags []Flag, writer io.Writer) { continue } // match if last argument matches this flag and it is not repeated - if strings.HasPrefix(name, cur) && cur != name && !cliArgContains(name, os.Args) { + if strings.HasPrefix(name, currentArg) && currentArg != name && !cliArgContains(name, os.Args) { flagCompletion := fmt.Sprintf("%s%s", strings.Repeat("-", count), name) if usage != "" && strings.HasSuffix(os.Getenv("SHELL"), "zsh") { flagCompletion = fmt.Sprintf("%s:%s", flagCompletion, usage)