diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/arg/arg.go | 124 | ||||
| -rw-r--r-- | internal/arg/arg_test.go | 122 | ||||
| -rw-r--r-- | internal/install/bash.go | 37 | ||||
| -rw-r--r-- | internal/install/fish.go | 69 | ||||
| -rw-r--r-- | internal/install/install.go | 176 | ||||
| -rw-r--r-- | internal/install/utils.go | 140 | ||||
| -rw-r--r-- | internal/install/zsh.go | 44 | ||||
| -rw-r--r-- | internal/tokener/tokener.go | 67 |
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 +} |
