diff --git a/fish_completions.go b/fish_completions.go new file mode 100644 index 000000000..dac814008 --- /dev/null +++ b/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 +} diff --git a/fish_completions_test.go b/fish_completions_test.go new file mode 100644 index 000000000..e5f9a4e1c --- /dev/null +++ b/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`) +}