Repository: cli/cli
Pull request #271 (2020-01-29 10:01:10)

Add fish completion support
Fish is now the default shell on macOS, so it's only fitting that we have completion support for it.

Adopting code from https://github.com/spf13/cobra/pull/754

TODO:
- [x] Move imported code to its own package
- [ ] Have our Homebrew formula set up fish completion on install


Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 40ab1acb4..ea3cf9369 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -21,6 +21,7 @@ jobs:
         run: go mod verify
 
       - name: Build
+        shell: bash
         run: |
           go test ./...
           go build -v ./cmd/gh
diff --git a/README.md b/README.md
index 47ceed220..56fee3f4f 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,10 @@
 # gh - The GitHub CLI tool
 
-`gh` is GitHub on the command line, and it's now available in beta. It brings pull requests, issues, and other GitHub concepts to
+`gh` is GitHub on the command line. It brings pull requests, issues, and other GitHub concepts to
 the terminal next to where you are already working with `git` and your code.
 
 ![screenshot](https://user-images.githubusercontent.com/98482/73286699-9f922180-41bd-11ea-87c9-60a2d31fd0ac.png)
 
-## We need your feedback
-
-GitHub CLI is currently early in its development, and we're hoping to get feedback from people using it.
-
-If you've installed and used `gh`, we'd love for you to take a short survey here (no more than five minutes): https://forms.gle/umxd3h31c7aMQFKG7
-
-And if you spot bugs or have features that you'd really like to see in `gh`, please check out the [contributing page][]
-
 ## Usage
 
 - `gh pr [status, list, view, checkout, create]`
@@ -59,4 +51,3 @@ project directory.
 [docs]: https://cli.github.io/cli/gh
 [releases page]: https://github.com/cli/cli/releases/latest
 [hub]: https://github.com/github/hub
-[contributing page]: https://github.com/cli/cli/blob/master/.github/CONTRIBUTING.md
diff --git a/command/completion.go b/command/completion.go
index 2a528a1ba..ee0e8978a 100644
--- a/command/completion.go
+++ b/command/completion.go
@@ -3,6 +3,7 @@ package command
 import (
 	"fmt"
 
+	"github.com/cli/cli/internal/cobrafish"
 	"github.com/spf13/cobra"
 )
 
@@ -35,8 +36,10 @@ When installing with Homebrew, see https://docs.brew.sh/Shell-Completion
 			RootCmd.GenBashCompletion(cmd.OutOrStdout())
 		case "zsh":
 			RootCmd.GenZshCompletion(cmd.OutOrStdout())
+		case "fish":
+			cobrafish.GenCompletion(RootCmd, cmd.OutOrStdout())
 		default:
-			return fmt.Errorf("unsupported shell type: %s", shellType)
+			return fmt.Errorf("unsupported shell type %q", shellType)
 		}
 		return nil
 	},
diff --git a/internal/cobrafish/completion.go b/internal/cobrafish/completion.go
new file mode 100644
index 000000000..5d70945f7
--- /dev/null
+++ b/internal/cobrafish/completion.go
@@ -0,0 +1,171 @@
+// imported from https://github.com/spf13/cobra/pull/754
+// author: Tim Reddehase
+
+package cobrafish
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+func GenCompletion(c *cobra.Command, w io.Writer) error {
+	buf := new(bytes.Buffer)
+
+	writeFishPreamble(c, buf)
+	writeFishCommandCompletion(c, c, buf)
+
+	_, err := buf.WriteTo(w)
+	return err
+}
+
+func writeFishPreamble(cmd *cobra.Command, buf *bytes.Buffer) {
+	subCommandNames := []string{}
+	rangeCommands(cmd, func(subCmd *cobra.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 *cobra.Command, buf *bytes.Buffer) {
+	rangeCommands(cmd, func(subCmd *cobra.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 *cobra.Command) {
+		writeFishCommandCompletion(rootCmd, subCmd, buf)
+	})
+}
+
+func writeCommandFlagsCompletion(rootCmd, cmd *cobra.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 *cobra.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 *cobra.Command, cmd *cobra.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 *cobra.Command, callback func(subCmd *cobra.Command)) {
+	for _, subCmd := range cmd.Commands() {
+		if !subCmd.IsAvailableCommand() || strings.HasPrefix(subCmd.Use, "help") {
+			continue
+		}
+		callback(subCmd)
+	}
+}
+
+func commandCompletionCondition(rootCmd, cmd *cobra.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 *cobra.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
+}
+
+func nonCompletableFlag(flag *pflag.Flag) bool {
+	return flag.Hidden || len(flag.Deprecated) > 0
+}


Test patch:
diff --git a/command/completion_test.go b/command/completion_test.go
index e66a00124..e8a15db56 100644
--- a/command/completion_test.go
+++ b/command/completion_test.go
@@ -27,9 +27,20 @@ func TestCompletion_zsh(t *testing.T) {
 	}
 }
 
+func TestCompletion_fish(t *testing.T) {
+	output, err := RunCommand(completionCmd, `completion -s fish`)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !strings.Contains(output.String(), "complete -c gh ") {
+		t.Errorf("problem in fish completion:\n%s", output)
+	}
+}
+
 func TestCompletion_unsupported(t *testing.T) {
-	_, err := RunCommand(completionCmd, `completion -s fish`)
-	if err == nil || err.Error() != "unsupported shell type: fish" {
+	_, err := RunCommand(completionCmd, `completion -s csh`)
+	if err == nil || err.Error() != `unsupported shell type "csh"` {
 		t.Fatal(err)
 	}
 }