Skip to content

Commit

Permalink
add fish completion support (spf13#754)
Browse files Browse the repository at this point in the history
* add fish completion basics to cobra.
* add helpCommand check to fish completions.
* add basic tests for fish completion.
* support ValidArgs completion of commands in fish.
* add additional support for argument aliases.
* remove oly mention.
* use custom function for checking subcommand path.
* use fish builtin for flag/argument checking.
* backport __fish_seen_argument.
* escape description of commands & flags.
* allow subcommand_path to match even with flags
* deal with unreachable code warning.
  • Loading branch information
0robustus1 authored and umarcor committed Jan 4, 2020
1 parent e5e87ad commit dd5345d
Show file tree
Hide file tree
Showing 2 changed files with 308 additions and 0 deletions.
167 changes: 167 additions & 0 deletions fish_completions.go
@@ -0,0 +1,167 @@
package cobra

import (
"bytes"
"fmt"
"io"
"strings"

"github.com/spf13/pflag"
)

// GenFishCompletion generates fish completion and writes to the passed writer.
func (c *Command) GenFishCompletion(w io.Writer) error {
buf := new(bytes.Buffer)

writeFishPreamble(c, buf)
writeFishCommandCompletion(c, c, buf)

_, err := buf.WriteTo(w)
return err
}

func writeFishPreamble(cmd *Command, buf *bytes.Buffer) {
subCommandNames := []string{}
rangeCommands(cmd, func(subCmd *Command) {
subCommandNames = append(subCommandNames, subCmd.Name())
})
buf.WriteString(fmt.Sprintf(`
function __fish_%s_no_subcommand --description 'Test if %s has yet to be given the subcommand'
for i in (commandline -opc)
if contains -- $i %s
return 1
end
end
return 0
end
function __fish_%s_seen_subcommand_path --description 'Test whether the full path of subcommands is the current path'
set -l cmd (commandline -opc)
set -e cmd[1]
set -l pattern (string replace -a " " ".+" "$argv")
string match -r "$pattern" (string trim -- "$cmd")
end
# borrowed from current fish-shell master, since it is not in current 2.7.1 release
function __fish_seen_argument
argparse 's/short=+' 'l/long=+' -- $argv
set cmd (commandline -co)
set -e cmd[1]
for t in $cmd
for s in $_flag_s
if string match -qr "^-[A-z0-9]*"$s"[A-z0-9]*\$" -- $t
return 0
end
end
for l in $_flag_l
if string match -q -- "--$l" $t
return 0
end
end
end
return 1
end
`, cmd.Name(), cmd.Name(), strings.Join(subCommandNames, " "), cmd.Name()))
}

func writeFishCommandCompletion(rootCmd, cmd *Command, buf *bytes.Buffer) {
rangeCommands(cmd, func(subCmd *Command) {
condition := commandCompletionCondition(rootCmd, cmd)
escapedDescription := strings.Replace(subCmd.Short, "'", "\\'", -1)
buf.WriteString(fmt.Sprintf("complete -c %s -f %s -a %s -d '%s'\n", rootCmd.Name(), condition, subCmd.Name(), escapedDescription))
})
for _, validArg := range append(cmd.ValidArgs, cmd.ArgAliases...) {
condition := commandCompletionCondition(rootCmd, cmd)
buf.WriteString(
fmt.Sprintf("complete -c %s -f %s -a %s -d '%s'\n",
rootCmd.Name(), condition, validArg, fmt.Sprintf("Positional Argument to %s", cmd.Name())))
}
writeCommandFlagsCompletion(rootCmd, cmd, buf)
rangeCommands(cmd, func(subCmd *Command) {
writeFishCommandCompletion(rootCmd, subCmd, buf)
})
}

func writeCommandFlagsCompletion(rootCmd, cmd *Command, buf *bytes.Buffer) {
cmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) {
if nonCompletableFlag(flag) {
return
}
writeCommandFlagCompletion(rootCmd, cmd, buf, flag)
})
cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) {
if nonCompletableFlag(flag) {
return
}
writeCommandFlagCompletion(rootCmd, cmd, buf, flag)
})
}

func writeCommandFlagCompletion(rootCmd, cmd *Command, buf *bytes.Buffer, flag *pflag.Flag) {
shortHandPortion := ""
if len(flag.Shorthand) > 0 {
shortHandPortion = fmt.Sprintf("-s %s", flag.Shorthand)
}
condition := completionCondition(rootCmd, cmd)
escapedUsage := strings.Replace(flag.Usage, "'", "\\'", -1)
buf.WriteString(fmt.Sprintf("complete -c %s -f %s %s %s -l %s -d '%s'\n",
rootCmd.Name(), condition, flagRequiresArgumentCompletion(flag), shortHandPortion, flag.Name, escapedUsage))
}

func flagRequiresArgumentCompletion(flag *pflag.Flag) string {
if flag.Value.Type() != "bool" {
return "-r"
}
return ""
}

func subCommandPath(rootCmd *Command, cmd *Command) string {
path := []string{}
currentCmd := cmd
if rootCmd == cmd {
return ""
}
for {
path = append([]string{currentCmd.Name()}, path...)
if currentCmd.Parent() == rootCmd {
return strings.Join(path, " ")
}
currentCmd = currentCmd.Parent()
}
}

func rangeCommands(cmd *Command, callback func(subCmd *Command)) {
for _, subCmd := range cmd.Commands() {
if !subCmd.IsAvailableCommand() || subCmd == cmd.helpCommand {
continue
}
callback(subCmd)
}
}

func commandCompletionCondition(rootCmd, cmd *Command) string {
localNonPersistentFlags := cmd.LocalNonPersistentFlags()
bareConditions := []string{}
if rootCmd != cmd {
bareConditions = append(bareConditions, fmt.Sprintf("__fish_%s_seen_subcommand_path %s", rootCmd.Name(), subCommandPath(rootCmd, cmd)))
} else {
bareConditions = append(bareConditions, fmt.Sprintf("__fish_%s_no_subcommand", rootCmd.Name()))
}
localNonPersistentFlags.VisitAll(func(flag *pflag.Flag) {
flagSelector := fmt.Sprintf("-l %s", flag.Name)
if len(flag.Shorthand) > 0 {
flagSelector = fmt.Sprintf("-s %s %s", flag.Shorthand, flagSelector)
}
bareConditions = append(bareConditions, fmt.Sprintf("not __fish_seen_argument %s", flagSelector))
})
return fmt.Sprintf("-n '%s'", strings.Join(bareConditions, "; and "))
}

func completionCondition(rootCmd, cmd *Command) string {
condition := fmt.Sprintf("-n '__fish_%s_no_subcommand'", rootCmd.Name())
if rootCmd != cmd {
condition = fmt.Sprintf("-n '__fish_%s_seen_subcommand_path %s'", rootCmd.Name(), subCommandPath(rootCmd, cmd))
}
return condition
}
141 changes: 141 additions & 0 deletions fish_completions_test.go
@@ -0,0 +1,141 @@
package cobra

import (
"bytes"
"testing"
)

func TestFishCompletions(t *testing.T) {
rootCmd := &Command{
Use: "root",
ArgAliases: []string{"pods", "nodes", "services", "replicationcontrollers", "po", "no", "svc", "rc"},
ValidArgs: []string{"pod", "node", "service", "replicationcontroller"},
Run: emptyRun,
}
rootCmd.Flags().IntP("introot", "i", -1, "help's message for flag introot")
rootCmd.MarkFlagRequired("introot")

// Filename.
rootCmd.Flags().String("filename", "", "Enter a filename")
rootCmd.MarkFlagFilename("filename", "json", "yaml", "yml")

// Persistent filename.
rootCmd.PersistentFlags().String("persistent-filename", "", "Enter a filename")
rootCmd.MarkPersistentFlagFilename("persistent-filename")
rootCmd.MarkPersistentFlagRequired("persistent-filename")

// Filename extensions.
rootCmd.Flags().String("filename-ext", "", "Enter a filename (extension limited)")
rootCmd.MarkFlagFilename("filename-ext")
rootCmd.Flags().String("custom", "", "Enter a filename (extension limited)")
rootCmd.MarkFlagCustom("custom", "__complete_custom")

// Subdirectories in a given directory.
rootCmd.Flags().String("theme", "", "theme to use (located in /themes/THEMENAME/)")

echoCmd := &Command{
Use: "echo [string to echo]",
Aliases: []string{"say"},
Short: "Echo anything's to the screen",
Long: "an utterly useless command for testing.",
Example: "Just run cobra-test echo",
Run: emptyRun,
}

echoCmd.Flags().String("filename", "", "Enter a filename")
echoCmd.MarkFlagFilename("filename", "json", "yaml", "yml")
echoCmd.Flags().String("config", "", "config to use (located in /config/PROFILE/)")

printCmd := &Command{
Use: "print [string to print]",
Args: MinimumNArgs(1),
Short: "Print anything to the screen",
Long: "an absolutely utterly useless command for testing.",
Run: emptyRun,
}

deprecatedCmd := &Command{
Use: "deprecated [can't do anything here]",
Args: NoArgs,
Short: "A command which is deprecated",
Long: "an absolutely utterly useless command for testing deprecation!.",
Deprecated: "Please use echo instead",
Run: emptyRun,
}

colonCmd := &Command{
Use: "cmd:colon",
Run: emptyRun,
}

timesCmd := &Command{
Use: "times [# times] [string to echo]",
SuggestFor: []string{"counts"},
Args: OnlyValidArgs,
ValidArgs: []string{"one", "two", "three", "four"},
Short: "Echo anything to the screen more times",
Long: "a slightly useless command for testing.",
Run: emptyRun,
}

echoCmd.AddCommand(timesCmd)
rootCmd.AddCommand(echoCmd, printCmd, deprecatedCmd, colonCmd)

buf := new(bytes.Buffer)
rootCmd.GenFishCompletion(buf)
output := buf.String()

// check for preamble helper functions
check(t, output, "__fish_root_no_subcommand")
check(t, output, "__fish_root_seen_subcommand_path")
check(t, output, "__fish_seen_argument")

// check for subcommands
check(t, output, "-a echo")
check(t, output, "-a print")
checkOmit(t, output, "-a deprecated")
check(t, output, "-a cmd:colon")

// check for nested subcommands
checkRegex(t, output, `-n '__fish_root_seen_subcommand_path echo(; and[^']*)?' -a times`)

// check for flags that will take arguments
check(t, output, "-n '__fish_root_no_subcommand' -r -s i -l introot")
check(t, output, "-n '__fish_root_no_subcommand' -r -l filename")
check(t, output, "-n '__fish_root_no_subcommand' -r -l persistent-filename")
check(t, output, "-n '__fish_root_no_subcommand' -r -l theme")
check(t, output, "-n '__fish_root_seen_subcommand_path echo' -r -l config")
check(t, output, "-n '__fish_root_seen_subcommand_path echo' -r -l filename")

// checks escape of description in flags
check(t, output, "-n '__fish_root_no_subcommand' -r -s i -l introot -d 'help\\'s message for flag introot'")

// check for persistent flags that will take arguments
check(t, output, "-n '__fish_root_seen_subcommand_path cmd:colon' -r -l persistent-filename")
check(t, output, "-n '__fish_root_seen_subcommand_path echo' -r -l persistent-filename")
check(t, output, "-n '__fish_root_seen_subcommand_path echo times' -r -l persistent-filename")
check(t, output, "-n '__fish_root_seen_subcommand_path print' -r -l persistent-filename")

// check for local non-persistent flags
checkRegex(t, output, `; and not __fish_seen_argument -l custom[^']*' -a echo`)
checkRegex(t, output, `; and not __fish_seen_argument -l filename[^']*' -a echo`)
checkRegex(t, output, `; and not __fish_seen_argument -l filename-ext[^']*' -a echo`)
checkRegex(t, output, `; and not __fish_seen_argument -s i -l introot[^']*' -a echo`)
checkRegex(t, output, `; and not __fish_seen_argument -l theme[^']*' -a echo`)

// check for positional arguments to a command
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a pod`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a node`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a service`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a replicationcontroller`)

// check for aliases to positional arguments for a command
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a pods`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a nodes`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a services`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a replicationcontrollers`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a po`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a no`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a svc`)
checkRegex(t, output, `-n '__fish_root_no_subcommand(; and[^']*)?' -a rc`)
}

0 comments on commit dd5345d

Please sign in to comment.