Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve the command for printing completion scripts #1998

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
13 changes: 6 additions & 7 deletions autocomplete/bash_autocomplete
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
#! /bin/bash
#!/usr/env/bin bash

: ${PROG:=$(basename ${BASH_SOURCE})}
# 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.
_cli_init_completion() {
__%[1]s_init_completion() {
COMPREPLY=()
_get_comp_words_by_ref "$@" cur prev words cword
}

_cli_bash_autocomplete() {
__%[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
_cli_init_completion -n "=:" || return
__%[1]s_init_completion -n "=:" || return
fi
words=("${words[@]:0:$cword}")
if [[ "$cur" == "-"* ]]; then
Expand All @@ -31,5 +31,4 @@ _cli_bash_autocomplete() {
fi
}

complete -o bashdefault -o default -o nospace -F _cli_bash_autocomplete $PROG
unset PROG
complete -o bashdefault -o default -o nospace -F __%[1]s_bash_autocomplete %[1]s
2 changes: 1 addition & 1 deletion autocomplete/powershell_autocomplete.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Register-ArgumentCompleter -Native -CommandName $name -ScriptBlock {
Invoke-Expression $other | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
}
31 changes: 15 additions & 16 deletions autocomplete/zsh_autocomplete
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
#compdef program
compdef _program program
#compdef %[1]s
compdef _%[1]s %[1]s

# 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
# This is a shell completion script auto-generated by https://github.com/urfave/cli for zsh.

_program() {
local -a opts
local cur
cur=${words[-1]}
if [[ "$cur" == "-"* ]]; then
opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-shell-completion)}")
_%[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

Expand All @@ -24,7 +22,8 @@ _program() {
fi
}

# don't run the completion function when being source-ed or eval-ed
if [ "$funcstack[1]" = "_program" ]; then
_program
# 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
2 changes: 1 addition & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
70 changes: 45 additions & 25 deletions completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,57 @@
)

const (
completionCommandName = "generate-completion"
completionFlagName = "generate-shell-completion"
completionFlag = "--" + completionFlagName
completionCommandName = "completion"

// 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(cmd *Command, appName string) (string, error)

var (
//go:embed autocomplete
autoCompleteFS embed.FS

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) {
"bash": func(c *Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("autocomplete/bash_autocomplete")
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}

Check warning on line 28 in completion.go

View check run for this annotation

Codecov / codecov/patch

completion.go#L27-L28

Added lines #L27 - L28 were not covered by tests
return fmt.Sprintf(string(b), appName), nil
},
"zsh": func(c *Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("autocomplete/zsh_autocomplete")
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}

Check warning on line 35 in completion.go

View check run for this annotation

Codecov / codecov/patch

completion.go#L34-L35

Added lines #L34 - L35 were not covered by tests
return fmt.Sprintf(string(b), appName), nil
},
"fish": func(c *Command, appName string) (string, error) {
return c.ToFishCompletion()
},
"pwsh": func(c *Command, appName string) (string, error) {
b, err := autoCompleteFS.ReadFile("autocomplete/powershell_autocomplete.ps1")
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}

Check warning on line 45 in completion.go

View check run for this annotation

Codecov / codecov/patch

completion.go#L44-L45

Added lines #L44 - L45 were not covered by tests
return string(b), nil
},
}
)

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
}
}

func buildCompletionCommand() *Command {
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)
Expand All @@ -57,14 +71,20 @@
}
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)
} else {
if _, err = cmd.Writer.Write([]byte(c)); err != nil {
return Exit(err, 1)
}
}

_, err = cmd.Writer.Write([]byte(completionScript))
if err != nil {
return Exit(err, 1)
}

return nil
}
2 changes: 1 addition & 1 deletion completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
70 changes: 70 additions & 0 deletions examples/simpletask/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont see the value of this example. Its not really doing anything. If you want to really test this move it into examples_test.go or call it func ExampleCompletion(...) in completion_test.go

Copy link
Member Author

@bartekpacia bartekpacia Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, I do see its value, it's a simple yet quite realistic CLI app. It's quite useful for testing shell completions, because it has a few subcommand and sets EnableShellCompletion: true.

Maybe I can modify example-cli or example-hello-world and add a few subcommand and EnableShellCompletion: true there?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find complete examples very useful. Sometimes you just need a bit of working code to debug something that doesn't work anymore. If completions break when migrating from v2 to v3, then using this v3 code as a reference, I could find the cause much faster.

Complete working examples are also useful for training AI.

It needs some better organization, though. Maybe even numbers to sort contents in the order people usually learn the library. By most frequent use cases.

https://github.com/urfave/cli/tree/ef45965eeb9c1122885fafa4a391b6be6a674f3d/examples

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the main reason why I put this sample in a new file is because examples_test is not easily runnable.

I agree with @abitrolly comment that it's be nice to have a single place for more "full app" examples.


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)
}
}
7 changes: 4 additions & 3 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading