summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/arg/arg.go124
-rw-r--r--internal/arg/arg_test.go122
-rw-r--r--internal/install/bash.go37
-rw-r--r--internal/install/fish.go69
-rw-r--r--internal/install/install.go176
-rw-r--r--internal/install/utils.go140
-rw-r--r--internal/install/zsh.go44
-rw-r--r--internal/tokener/tokener.go67
8 files changed, 779 insertions, 0 deletions
diff --git a/internal/arg/arg.go b/internal/arg/arg.go
new file mode 100644
index 0000000..0577d74
--- /dev/null
+++ b/internal/arg/arg.go
@@ -0,0 +1,124 @@
+package arg
+
+import "strings"
+
+import "github.com/posener/complete/internal/tokener"
+
+// Arg is typed a command line argument.
+type Arg struct {
+ Text string
+ Completed bool
+ Parsed
+}
+
+// Parsed contains information about the argument.
+type Parsed struct {
+ Flag string
+ HasFlag bool
+ Value string
+ Dashes string
+ HasValue bool
+}
+
+// Parse parses a typed command line argument list, and returns a list of arguments.
+func Parse(line string) []Arg {
+ var args []Arg
+ for {
+ arg, after := next(line)
+ if arg.Text != "" {
+ args = append(args, arg)
+ }
+ line = after
+ if line == "" {
+ break
+ }
+ }
+ return args
+}
+
+// next returns the first argument in the line and the rest of the line.
+func next(line string) (arg Arg, after string) {
+ defer arg.parse()
+ // Start and end of the argument term.
+ var start, end int
+ // Stack of quote marks met during the paring of the argument.
+ var token tokener.Tokener
+ // Skip prefix spaces.
+ for start = 0; start < len(line); start++ {
+ token.Visit(line[start])
+ if !token.LastSpace() {
+ break
+ }
+ }
+ // If line is only spaces, return empty argument and empty leftovers.
+ if start == len(line) {
+ return
+ }
+
+ for end = start + 1; end < len(line); end++ {
+ token.Visit(line[end])
+ if token.LastSpace() {
+ arg.Completed = true
+ break
+ }
+ }
+ arg.Text = line[start:end]
+ if !arg.Completed {
+ return
+ }
+ start2 := end
+
+ // Skip space after word.
+ for start2 < len(line) {
+ token.Visit(line[start2])
+ if !token.LastSpace() {
+ break
+ }
+ start2++
+ }
+ after = line[start2:]
+ return
+}
+
+// parse a flag from an argument. The flag can have value attached when it is given in the
+// `-key=value` format.
+func (a *Arg) parse() {
+ if len(a.Text) == 0 {
+ return
+ }
+
+ // A pure value, no flag.
+ if a.Text[0] != '-' {
+ a.Value = a.Text
+ a.HasValue = true
+ return
+ }
+
+ // Seprate the dashes from the flag name.
+ dahsI := 1
+ if len(a.Text) > 1 && a.Text[1] == '-' {
+ dahsI = 2
+ }
+ a.Dashes = a.Text[:dahsI]
+ a.HasFlag = true
+ a.Flag = a.Text[dahsI:]
+
+ // Empty flag
+ if a.Flag == "" {
+ return
+ }
+ // Third dash or empty flag with equal is forbidden.
+ if a.Flag[0] == '-' || a.Flag[0] == '=' {
+ a.Parsed = Parsed{}
+ return
+ }
+ // The flag is valid.
+
+ // Check if flag has a value.
+ if equal := strings.IndexRune(a.Flag, '='); equal != -1 {
+ a.Flag, a.Value = a.Flag[:equal], a.Flag[equal+1:]
+ a.HasValue = true
+ return
+ }
+
+}
diff --git a/internal/arg/arg_test.go b/internal/arg/arg_test.go
new file mode 100644
index 0000000..11130ce
--- /dev/null
+++ b/internal/arg/arg_test.go
@@ -0,0 +1,122 @@
+package arg
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParse(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ line string
+ args []Arg
+ }{
+ {
+ line: "a b",
+ args: []Arg{{Text: "a", Completed: true}, {Text: "b", Completed: false}},
+ },
+ {
+ line: " a b ",
+ args: []Arg{{Text: "a", Completed: true}, {Text: "b", Completed: true}},
+ },
+ {
+ line: "a b",
+ args: []Arg{{Text: "a", Completed: true}, {Text: "b", Completed: false}},
+ },
+ {
+ line: " a ",
+ args: []Arg{{Text: "a", Completed: true}},
+ },
+ {
+ line: " a",
+ args: []Arg{{Text: "a", Completed: false}},
+ },
+ {
+ line: " ",
+ args: nil,
+ },
+ {
+ line: "",
+ args: nil,
+ },
+ {
+ line: `\ a\ b c\ `,
+ args: []Arg{{Text: `\ a\ b`, Completed: true}, {Text: `c\ `, Completed: false}},
+ },
+ {
+ line: `"\"'\''" '"'`,
+ args: []Arg{{Text: `"\"'\''"`, Completed: true}, {Text: `'"'`, Completed: false}},
+ },
+ {
+ line: `"a b"`,
+ args: []Arg{{Text: `"a b"`, Completed: false}},
+ },
+ {
+ line: `"a b" `,
+ args: []Arg{{Text: `"a b"`, Completed: true}},
+ },
+ {
+ line: `"a b"c`,
+ args: []Arg{{Text: `"a b"c`, Completed: false}},
+ },
+ {
+ line: `"a b"c `,
+ args: []Arg{{Text: `"a b"c`, Completed: true}},
+ },
+ {
+ line: `"a b" c`,
+ args: []Arg{{Text: `"a b"`, Completed: true}, {Text: "c", Completed: false}},
+ },
+ {
+ line: `"a `,
+ args: []Arg{{Text: `"a `, Completed: false}},
+ },
+ {
+ line: `\"a b`,
+ args: []Arg{{Text: `\"a`, Completed: true}, {Text: "b", Completed: false}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.line, func(t *testing.T) {
+ args := Parse(tt.line)
+ // Clear parsed part of the arguments. It is tested in the TestArgsParsed test.
+ for i := range args {
+ arg := args[i]
+ arg.Parsed = Parsed{}
+ args[i] = arg
+ }
+ assert.Equal(t, tt.args, args)
+ })
+ }
+}
+
+func TestArgsParsed(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ text string
+ parsed Parsed
+ }{
+ {text: "-", parsed: Parsed{Dashes: "-", HasFlag: true}},
+ {text: "--", parsed: Parsed{Dashes: "--", HasFlag: true}},
+ {text: "---"}, // Forbidden.
+ {text: "--="}, // Forbidden.
+ {text: "-="}, // Forbidden.
+ {text: "-a-b", parsed: Parsed{Dashes: "-", Flag: "a-b", HasFlag: true}},
+ {text: "--a-b", parsed: Parsed{Dashes: "--", Flag: "a-b", HasFlag: true}},
+ {text: "-a-b=c-d=e", parsed: Parsed{Dashes: "-", Flag: "a-b", HasFlag: true, Value: "c-d=e", HasValue: true}},
+ {text: "--a-b=c-d=e", parsed: Parsed{Dashes: "--", Flag: "a-b", HasFlag: true, Value: "c-d=e", HasValue: true}},
+ {text: "--a-b=", parsed: Parsed{Dashes: "--", Flag: "a-b", HasFlag: true, Value: "", HasValue: true}},
+ {text: "a", parsed: Parsed{Value: "a", HasValue: true}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.text, func(t *testing.T) {
+ arg := Parse(tt.text)[0]
+ assert.Equal(t, tt.parsed, arg.Parsed)
+ })
+ }
+}
diff --git a/internal/install/bash.go b/internal/install/bash.go
new file mode 100644
index 0000000..17c64de
--- /dev/null
+++ b/internal/install/bash.go
@@ -0,0 +1,37 @@
+package install
+
+import "fmt"
+
+// (un)install in bash
+// basically adds/remove from .bashrc:
+//
+// complete -C </path/to/completion/command> <command>
+type bash struct {
+ rc string
+}
+
+func (b bash) IsInstalled(cmd, bin string) bool {
+ completeCmd := b.cmd(cmd, bin)
+ return lineInFile(b.rc, completeCmd)
+}
+
+func (b bash) Install(cmd, bin string) error {
+ if b.IsInstalled(cmd, bin) {
+ return fmt.Errorf("already installed in %s", b.rc)
+ }
+ completeCmd := b.cmd(cmd, bin)
+ return appendToFile(b.rc, completeCmd)
+}
+
+func (b bash) Uninstall(cmd, bin string) error {
+ if !b.IsInstalled(cmd, bin) {
+ return fmt.Errorf("does not installed in %s", b.rc)
+ }
+
+ completeCmd := b.cmd(cmd, bin)
+ return removeFromFile(b.rc, completeCmd)
+}
+
+func (bash) cmd(cmd, bin string) string {
+ return fmt.Sprintf("complete -C %s %s", bin, cmd)
+}
diff --git a/internal/install/fish.go b/internal/install/fish.go
new file mode 100644
index 0000000..2b64bfc
--- /dev/null
+++ b/internal/install/fish.go
@@ -0,0 +1,69 @@
+package install
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "text/template"
+)
+
+// (un)install in fish
+
+type fish struct {
+ configDir string
+}
+
+func (f fish) IsInstalled(cmd, bin string) bool {
+ completionFile := f.getCompletionFilePath(cmd)
+ if _, err := os.Stat(completionFile); err == nil {
+ return true
+ }
+ return false
+}
+
+func (f fish) Install(cmd, bin string) error {
+ if f.IsInstalled(cmd, bin) {
+ return fmt.Errorf("already installed at %s", f.getCompletionFilePath(cmd))
+ }
+
+ completionFile := f.getCompletionFilePath(cmd)
+ completeCmd, err := f.cmd(cmd, bin)
+ if err != nil {
+ return err
+ }
+
+ return createFile(completionFile, completeCmd)
+}
+
+func (f fish) Uninstall(cmd, bin string) error {
+ if !f.IsInstalled(cmd, bin) {
+ return fmt.Errorf("does not installed in %s", f.configDir)
+ }
+
+ completionFile := f.getCompletionFilePath(cmd)
+ return os.Remove(completionFile)
+}
+
+func (f fish) getCompletionFilePath(cmd string) string {
+ return filepath.Join(f.configDir, "completions", fmt.Sprintf("%s.fish", cmd))
+}
+
+func (f fish) cmd(cmd, bin string) (string, error) {
+ var buf bytes.Buffer
+ params := struct{ Cmd, Bin string }{cmd, bin}
+ tmpl := template.Must(template.New("cmd").Parse(`
+function __complete_{{.Cmd}}
+ set -lx COMP_LINE (commandline -cp)
+ test -z (commandline -ct)
+ and set COMP_LINE "$COMP_LINE "
+ {{.Bin}}
+end
+complete -f -c {{.Cmd}} -a "(__complete_{{.Cmd}})"
+`))
+ err := tmpl.Execute(&buf, params)
+ if err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/internal/install/install.go b/internal/install/install.go
new file mode 100644
index 0000000..e4c5c0e
--- /dev/null
+++ b/internal/install/install.go
@@ -0,0 +1,176 @@
+package install
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/hashicorp/go-multierror"
+)
+
+func Run(name string, uninstall, yes bool, out io.Writer, in io.Reader) {
+ action := "install"
+ if uninstall {
+ action = "uninstall"
+ }
+ if !yes {
+ fmt.Fprintf(out, "%s completion for %s? ", action, name)
+ var answer string
+ fmt.Fscanln(in, &answer)
+ switch strings.ToLower(answer) {
+ case "y", "yes":
+ default:
+ fmt.Fprintf(out, "Cancelling...")
+ return
+ }
+ }
+ fmt.Fprintf(out, action+"ing...")
+
+ if uninstall {
+ Uninstall(name)
+ } else {
+ Install(name)
+ }
+}
+
+type installer interface {
+ IsInstalled(cmd, bin string) bool
+ Install(cmd, bin string) error
+ Uninstall(cmd, bin string) error
+}
+
+// Install complete command given:
+// cmd: is the command name
+func Install(cmd string) error {
+ is := installers()
+ if len(is) == 0 {
+ return errors.New("Did not find any shells to install")
+ }
+ bin, err := getBinaryPath()
+ if err != nil {
+ return err
+ }
+
+ for _, i := range is {
+ errI := i.Install(cmd, bin)
+ if errI != nil {
+ err = multierror.Append(err, errI)
+ }
+ }
+
+ return err
+}
+
+// IsInstalled returns true if the completion
+// for the given cmd is installed.
+func IsInstalled(cmd string) bool {
+ bin, err := getBinaryPath()
+ if err != nil {
+ return false
+ }
+
+ for _, i := range installers() {
+ installed := i.IsInstalled(cmd, bin)
+ if installed {
+ return true
+ }
+ }
+
+ return false
+}
+
+// Uninstall complete command given:
+// cmd: is the command name
+func Uninstall(cmd string) error {
+ is := installers()
+ if len(is) == 0 {
+ return errors.New("Did not find any shells to uninstall")
+ }
+ bin, err := getBinaryPath()
+ if err != nil {
+ return err
+ }
+
+ for _, i := range is {
+ errI := i.Uninstall(cmd, bin)
+ if errI != nil {
+ err = multierror.Append(err, errI)
+ }
+ }
+
+ return err
+}
+
+func installers() (i []installer) {
+ // The list of bash config files candidates where it is
+ // possible to install the completion command.
+ var bashConfFiles []string
+ switch runtime.GOOS {
+ case "darwin":
+ bashConfFiles = []string{".bash_profile"}
+ default:
+ bashConfFiles = []string{".bashrc", ".bash_profile", ".bash_login", ".profile"}
+ }
+ for _, rc := range bashConfFiles {
+ if f := rcFile(rc); f != "" {
+ i = append(i, bash{f})
+ break
+ }
+ }
+ if f := rcFile(".zshrc"); f != "" {
+ i = append(i, zsh{f})
+ }
+ if d := fishConfigDir(); d != "" {
+ i = append(i, fish{d})
+ }
+ return
+}
+
+func fishConfigDir() string {
+ configDir := filepath.Join(getConfigHomePath(), "fish")
+ if configDir == "" {
+ return ""
+ }
+ if info, err := os.Stat(configDir); err != nil || !info.IsDir() {
+ return ""
+ }
+ return configDir
+}
+
+func getConfigHomePath() string {
+ u, err := user.Current()
+ if err != nil {
+ return ""
+ }
+
+ configHome := os.Getenv("XDG_CONFIG_HOME")
+ if configHome == "" {
+ return filepath.Join(u.HomeDir, ".config")
+ }
+ return configHome
+}
+
+func getBinaryPath() (string, error) {
+ bin, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Abs(bin)
+}
+
+func rcFile(name string) string {
+ u, err := user.Current()
+ if err != nil {
+ return ""
+ }
+ path := filepath.Join(u.HomeDir, name)
+ if _, err := os.Stat(path); err != nil {
+ return ""
+ }
+ return path
+}
diff --git a/internal/install/utils.go b/internal/install/utils.go
new file mode 100644
index 0000000..d34ac8c
--- /dev/null
+++ b/internal/install/utils.go
@@ -0,0 +1,140 @@
+package install
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+)
+
+func lineInFile(name string, lookFor string) bool {
+ f, err := os.Open(name)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+ r := bufio.NewReader(f)
+ prefix := []byte{}
+ for {
+ line, isPrefix, err := r.ReadLine()
+ if err == io.EOF {
+ return false
+ }
+ if err != nil {
+ return false
+ }
+ if isPrefix {
+ prefix = append(prefix, line...)
+ continue
+ }
+ line = append(prefix, line...)
+ if string(line) == lookFor {
+ return true
+ }
+ prefix = prefix[:0]
+ }
+}
+
+func createFile(name string, content string) error {
+ // make sure file directory exists
+ if err := os.MkdirAll(filepath.Dir(name), 0775); err != nil {
+ return err
+ }
+
+ // create the file
+ f, err := os.Create(name)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // write file content
+ _, err = f.WriteString(fmt.Sprintf("%s\n", content))
+ return err
+}
+
+func appendToFile(name string, content string) error {
+ f, err := os.OpenFile(name, os.O_RDWR|os.O_APPEND, 0)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = f.WriteString(fmt.Sprintf("\n%s\n", content))
+ return err
+}
+
+func removeFromFile(name string, content string) error {
+ backup := name + ".bck"
+ err := copyFile(name, backup)
+ if err != nil {
+ return err
+ }
+ temp, err := removeContentToTempFile(name, content)
+ if err != nil {
+ return err
+ }
+
+ err = copyFile(temp, name)
+ if err != nil {
+ return err
+ }
+
+ return os.Remove(backup)
+}
+
+func removeContentToTempFile(name, content string) (string, error) {
+ rf, err := os.Open(name)
+ if err != nil {
+ return "", err
+ }
+ defer rf.Close()
+ wf, err := ioutil.TempFile("/tmp", "complete-")
+ if err != nil {
+ return "", err
+ }
+ defer wf.Close()
+
+ r := bufio.NewReader(rf)
+ prefix := []byte{}
+ for {
+ line, isPrefix, err := r.ReadLine()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return "", err
+ }
+ if isPrefix {
+ prefix = append(prefix, line...)
+ continue
+ }
+ line = append(prefix, line...)
+ str := string(line)
+ if str == content {
+ continue
+ }
+ _, err = wf.WriteString(str + "\n")
+ if err != nil {
+ return "", err
+ }
+ prefix = prefix[:0]
+ }
+ return wf.Name(), nil
+}
+
+func copyFile(src string, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, in)
+ return err
+}
diff --git a/internal/install/zsh.go b/internal/install/zsh.go
new file mode 100644
index 0000000..29950ab
--- /dev/null
+++ b/internal/install/zsh.go
@@ -0,0 +1,44 @@
+package install
+
+import "fmt"
+
+// (un)install in zsh
+// basically adds/remove from .zshrc:
+//
+// autoload -U +X bashcompinit && bashcompinit"
+// complete -C </path/to/completion/command> <command>
+type zsh struct {
+ rc string
+}
+
+func (z zsh) IsInstalled(cmd, bin string) bool {
+ completeCmd := z.cmd(cmd, bin)
+ return lineInFile(z.rc, completeCmd)
+}
+
+func (z zsh) Install(cmd, bin string) error {
+ if z.IsInstalled(cmd, bin) {
+ return fmt.Errorf("already installed in %s", z.rc)
+ }
+
+ completeCmd := z.cmd(cmd, bin)
+ bashCompInit := "autoload -U +X bashcompinit && bashcompinit"
+ if !lineInFile(z.rc, bashCompInit) {
+ completeCmd = bashCompInit + "\n" + completeCmd
+ }
+
+ return appendToFile(z.rc, completeCmd)
+}
+
+func (z zsh) Uninstall(cmd, bin string) error {
+ if !z.IsInstalled(cmd, bin) {
+ return fmt.Errorf("does not installed in %s", z.rc)
+ }
+
+ completeCmd := z.cmd(cmd, bin)
+ return removeFromFile(z.rc, completeCmd)
+}
+
+func (zsh) cmd(cmd, bin string) string {
+ return fmt.Sprintf("complete -o nospace -C %s %s", bin, cmd)
+}
diff --git a/internal/tokener/tokener.go b/internal/tokener/tokener.go
new file mode 100644
index 0000000..0886341
--- /dev/null
+++ b/internal/tokener/tokener.go
@@ -0,0 +1,67 @@
+package tokener
+
+type Tokener struct {
+ quotes []byte
+ escaped bool
+ fixed string
+ space bool
+}
+
+// Visit visit a byte and update the state of the quotes.
+// It returns true if the byte was quotes or escape character.
+func (t *Tokener) Visit(b byte) {
+ // Check space.
+ if b == ' ' {
+ if !t.escaped && !t.Quoted() {
+ t.space = true
+ }
+ } else {
+ t.space = false
+ }
+
+ // Check escaping
+ if b == '\\' {
+ t.escaped = !t.escaped
+ } else {
+ defer func() { t.escaped = false }()
+ }
+
+ // Check quotes.
+ if !t.escaped && (b == '"' || b == '\'') {
+ if t.Quoted() && t.quotes[len(t.quotes)-1] == b {
+ t.quotes = t.quotes[:len(t.quotes)-1]
+ } else {
+ t.quotes = append(t.quotes, b)
+ }
+ }
+
+ // If not quoted, insert escape before inserting space.
+ if t.LastSpace() {
+ t.fixed += "\\"
+ }
+ t.fixed += string(b)
+}
+
+func (t *Tokener) Escaped() bool {
+ return t.escaped
+}
+
+func (t *Tokener) Quoted() bool {
+ return len(t.quotes) > 0
+}
+
+func (t *Tokener) Fixed() string {
+ return t.fixed
+}
+
+func (t *Tokener) Closed() string {
+ fixed := t.fixed
+ for i := len(t.quotes) - 1; i >= 0; i-- {
+ fixed += string(t.quotes[i])
+ }
+ return fixed
+}
+
+func (t *Tokener) LastSpace() bool {
+ return t.space
+}