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

Merged
merged 21 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from 15 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

abitrolly marked this conversation as resolved.
Show resolved Hide resolved
: ${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")
Copy link
Contributor

Choose a reason for hiding this comment

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

i wonder if we can ignore the error since we are loading from embedFS. Saves us from writing unit tests as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

nice catch! I think it's not worth worrying about the error here

Copy link
Member Author

@bartekpacia bartekpacia Nov 24, 2024

Choose a reason for hiding this comment

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

I just decided to not additionaly wrap the errors to minimize amount of changes.

I think it's fine now.

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

example/simpletask.go doesn't tell anything to the person who browses examples. For the sake of bikeshedding examples/commands.go may be a better name.

I understand that this example can be used to test command completions, but because it is not used actually used in tests, I would suggest to submit it in a separate PR, where we could also discuss how to integrate it with what we have in docs.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea, I agree. I will add the simpletask example in a separate PR.

Copy link
Member Author

@bartekpacia bartekpacia Nov 24, 2024

Choose a reason for hiding this comment

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

Oh, actually I think I have another reason why I like /usr/bin/env bash.

For example on macOS, the /bin/bash is a 20-years old bash3, and almost everyone using bash installs a modern bash5 with Homebrew.

Output on my machine:

$ which -a bash
/opt/homebrew/bin/bash
/bin/bash

So I think hardcoding /bin/bash in this case will always default to the pre-installed bash3, which I think is not so nice.

wdyt?


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, "-")
bartekpacia marked this conversation as resolved.
Show resolved Hide resolved
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) {
bartekpacia marked this conversation as resolved.
Show resolved Hide resolved
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