From 7f287b62fb59473228b6b5682da1451359993aa6 Mon Sep 17 00:00:00 2001 From: Zachary Yedidia Date: Thu, 23 Nov 2017 23:04:32 -0500 Subject: [PATCH] Fix autocomplete behavior for empty args This also adds a modified version of go-shellwords as a dependency and removes the dependency on the original go-shellwords. --- .gitmodules | 3 - cmd/micro/actions.go | 3 +- cmd/micro/command.go | 11 +- cmd/micro/messenger.go | 5 +- cmd/micro/shellwords/LICENSE | 21 ++ cmd/micro/shellwords/README.md | 49 ++++ cmd/micro/shellwords/shellwords.go | 189 +++++++++++++++ cmd/micro/shellwords/shellwords_test.go | 229 ++++++++++++++++++ cmd/micro/shellwords/util_posix.go | 22 ++ cmd/micro/shellwords/util_windows.go | 20 ++ cmd/micro/util.go | 51 ---- .../vendor/github.com/mattn/go-shellwords | 1 - 12 files changed, 541 insertions(+), 63 deletions(-) create mode 100644 cmd/micro/shellwords/LICENSE create mode 100644 cmd/micro/shellwords/README.md create mode 100644 cmd/micro/shellwords/shellwords.go create mode 100644 cmd/micro/shellwords/shellwords_test.go create mode 100644 cmd/micro/shellwords/util_posix.go create mode 100644 cmd/micro/shellwords/util_windows.go delete mode 160000 cmd/micro/vendor/github.com/mattn/go-shellwords diff --git a/.gitmodules b/.gitmodules index 6b75f5a1..2d9bffcd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -55,6 +55,3 @@ [submodule "cmd/micro/vendor/github.com/flynn/json5"] path = cmd/micro/vendor/github.com/flynn/json5 url = https://github.com/flynn/json5 -[submodule "cmd/micro/vendor/github.com/mattn/go-shellwords"] - path = cmd/micro/vendor/github.com/mattn/go-shellwords - url = https://github.com/mattn/go-shellwords diff --git a/cmd/micro/actions.go b/cmd/micro/actions.go index a7a5e09a..566cd797 100644 --- a/cmd/micro/actions.go +++ b/cmd/micro/actions.go @@ -10,6 +10,7 @@ import ( "github.com/yuin/gopher-lua" "github.com/zyedidia/clipboard" + "github.com/zyedidia/micro/cmd/micro/shellwords" "github.com/zyedidia/tcell" ) @@ -997,7 +998,7 @@ func (v *View) SaveAs(usePlugin bool) bool { filename, canceled := messenger.Prompt("Filename: ", "", "Save", NoCompletion) if !canceled { // the filename might or might not be quoted, so unquote first then join the strings. - args, err := SplitCommandArgs(filename) + args, err := shellwords.Split(filename) filename = strings.Join(args, " ") if err != nil { messenger.Error("Error parsing arguments: ", err) diff --git a/cmd/micro/command.go b/cmd/micro/command.go index 2a848884..61f321ab 100644 --- a/cmd/micro/command.go +++ b/cmd/micro/command.go @@ -13,6 +13,7 @@ import ( "strings" humanize "github.com/dustin/go-humanize" + "github.com/zyedidia/micro/cmd/micro/shellwords" ) // A Command contains a action (a function to call) as well as information about how to autocomplete the command @@ -285,7 +286,7 @@ func Open(args []string) { if len(args) > 0 { filename := args[0] // the filename might or might not be quoted, so unquote first then join the strings. - args, err := SplitCommandArgs(filename) + args, err := shellwords.Split(filename) if err != nil { messenger.Error("Error parsing args ", err) return @@ -500,7 +501,7 @@ func Bind(args []string) { // Run runs a shell command in the background func Run(args []string) { // Run a shell command in the background (openTerm is false) - HandleShellCommand(JoinCommandArgs(args...), false, true) + HandleShellCommand(shellwords.Join(args...), false, true) } // Quit closes the main view @@ -645,7 +646,7 @@ func ReplaceAll(args []string) { // RunShellCommand executes a shell command and returns the output/error func RunShellCommand(input string) (string, error) { - args, err := SplitCommandArgs(input) + args, err := shellwords.Split(input) if err != nil { return "", err } @@ -665,7 +666,7 @@ func RunShellCommand(input string) (string, error) { // The openTerm argument specifies whether a terminal should be opened (for viewing output // or interacting with stdin) func HandleShellCommand(input string, openTerm bool, waitToFinish bool) string { - args, err := SplitCommandArgs(input) + args, err := shellwords.Split(input) if err != nil { return "" } @@ -735,7 +736,7 @@ func HandleShellCommand(input string, openTerm bool, waitToFinish bool) string { // HandleCommand handles input from the user func HandleCommand(input string) { - args, err := SplitCommandArgs(input) + args, err := shellwords.Split(input) if err != nil { messenger.Error("Error parsing args ", err) return diff --git a/cmd/micro/messenger.go b/cmd/micro/messenger.go index d281f017..05c0bef3 100644 --- a/cmd/micro/messenger.go +++ b/cmd/micro/messenger.go @@ -10,6 +10,7 @@ import ( "github.com/mattn/go-runewidth" "github.com/zyedidia/clipboard" + "github.com/zyedidia/micro/cmd/micro/shellwords" "github.com/zyedidia/tcell" ) @@ -272,7 +273,7 @@ func (m *Messenger) Prompt(prompt, placeholder, historyType string, completionTy response, canceled = m.response, false m.history[historyType][len(m.history[historyType])-1] = response case tcell.KeyTab: - args, err := SplitCommandArgs(m.response) + args, err := shellwords.Split(m.response) if err != nil { break } @@ -322,7 +323,7 @@ func (m *Messenger) Prompt(prompt, placeholder, historyType string, completionTy } if chosen != "" { - m.response = JoinCommandArgs(append(args[:len(args)-1], chosen)...) + m.response = shellwords.Join(append(args[:len(args)-1], chosen)...) m.cursorx = Count(m.response) } } diff --git a/cmd/micro/shellwords/LICENSE b/cmd/micro/shellwords/LICENSE new file mode 100644 index 00000000..740fa931 --- /dev/null +++ b/cmd/micro/shellwords/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Yasuhiro Matsumoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/micro/shellwords/README.md b/cmd/micro/shellwords/README.md new file mode 100644 index 00000000..d77ee695 --- /dev/null +++ b/cmd/micro/shellwords/README.md @@ -0,0 +1,49 @@ +This is a modified version of `go-shellwords` for the micro editor. + +# go-shellwords + +[![Coverage Status](https://coveralls.io/repos/mattn/go-shellwords/badge.png?branch=master)](https://coveralls.io/r/mattn/go-shellwords?branch=master) +[![Build Status](https://travis-ci.org/mattn/go-shellwords.svg?branch=master)](https://travis-ci.org/mattn/go-shellwords) + +Parse line as shell words. + +## Usage + +```go +args, err := shellwords.Parse("./foo --bar=baz") +// args should be ["./foo", "--bar=baz"] +``` + +```go +os.Setenv("FOO", "bar") +p := shellwords.NewParser() +p.ParseEnv = true +args, err := p.Parse("./foo $FOO") +// args should be ["./foo", "bar"] +``` + +```go +p := shellwords.NewParser() +p.ParseBacktick = true +args, err := p.Parse("./foo `echo $SHELL`") +// args should be ["./foo", "/bin/bash"] +``` + +```go +shellwords.ParseBacktick = true +p := shellwords.NewParser() +args, err := p.Parse("./foo `echo $SHELL`") +// args should be ["./foo", "/bin/bash"] +``` + +# Thanks + +This is based on cpan module [Parse::CommandLine](https://metacpan.org/pod/Parse::CommandLine). + +# License + +under the MIT License: http://mattn.mit-license.org/2017 + +# Author + +Yasuhiro Matsumoto (a.k.a mattn) diff --git a/cmd/micro/shellwords/shellwords.go b/cmd/micro/shellwords/shellwords.go new file mode 100644 index 00000000..b99c27d0 --- /dev/null +++ b/cmd/micro/shellwords/shellwords.go @@ -0,0 +1,189 @@ +package shellwords + +import ( + "bytes" + "errors" + "os" + "regexp" +) + +var envRe = regexp.MustCompile(`\$({[a-zA-Z0-9_]+}|[a-zA-Z0-9_]+)`) + +func isSpace(r rune) bool { + switch r { + case ' ', '\t', '\r', '\n': + return true + } + return false +} + +func replaceEnv(s string) string { + return envRe.ReplaceAllStringFunc(s, func(s string) string { + s = s[1:] + if s[0] == '{' { + s = s[1 : len(s)-1] + } + return os.Getenv(s) + }) +} + +type Parser struct { + Position int +} + +func NewParser() *Parser { + return &Parser{0} +} + +func (p *Parser) Parse(line string) ([]string, error) { + args := []string{} + buf := "" + var escaped, doubleQuoted, singleQuoted, backQuote, dollarQuote bool + backtick := "" + + pos := -1 + got := false + +loop: + for i, r := range line { + if escaped { + buf += string(r) + escaped = false + continue + } + + if r == '\\' { + if singleQuoted { + buf += string(r) + } else { + escaped = true + } + continue + } + + if isSpace(r) { + if singleQuoted || doubleQuoted || backQuote || dollarQuote { + buf += string(r) + backtick += string(r) + } else if got { + buf = replaceEnv(buf) + args = append(args, buf) + buf = "" + got = false + } + continue + } + + switch r { + case '`': + if !singleQuoted && !doubleQuoted && !dollarQuote { + if backQuote { + out, err := shellRun(backtick) + if err != nil { + return nil, err + } + buf = out + } + backtick = "" + backQuote = !backQuote + continue + backtick = "" + backQuote = !backQuote + } + case ')': + if !singleQuoted && !doubleQuoted && !backQuote { + if dollarQuote { + out, err := shellRun(backtick) + if err != nil { + return nil, err + } + buf = out + } + backtick = "" + dollarQuote = !dollarQuote + continue + backtick = "" + dollarQuote = !dollarQuote + } + case '(': + if !singleQuoted && !doubleQuoted && !backQuote { + if !dollarQuote && len(buf) > 0 && buf == "$" { + dollarQuote = true + buf += "(" + continue + } else { + return nil, errors.New("invalid command line string") + } + } + case '"': + if !singleQuoted && !dollarQuote { + doubleQuoted = !doubleQuoted + continue + } + case '\'': + if !doubleQuoted && !dollarQuote { + singleQuoted = !singleQuoted + continue + } + case ';', '&', '|', '<', '>': + if !(escaped || singleQuoted || doubleQuoted || backQuote) { + pos = i + break loop + } + } + + got = true + buf += string(r) + if backQuote || dollarQuote { + backtick += string(r) + } + } + + buf = replaceEnv(buf) + args = append(args, buf) + + if escaped || singleQuoted || doubleQuoted || backQuote || dollarQuote { + return nil, errors.New("invalid command line string") + } + + p.Position = pos + + return args, nil +} + +func Split(line string) ([]string, error) { + return NewParser().Parse(line) +} + +func Join(args ...string) string { + var buf bytes.Buffer + for i, w := range args { + if i != 0 { + buf.WriteByte(' ') + } + if w == "" { + buf.WriteString("''") + continue + } + + for _, b := range w { + switch b { + case 'a', 'b', 'c', 'd', 'e', 'f', 'g', + 'h', 'i', 'j', 'k', 'l', 'm', 'n', + 'o', 'p', 'q', 'r', 's', 't', 'u', + 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', + 'O', 'P', 'Q', 'R', 'S', 'T', 'U', + 'V', 'W', 'X', 'Y', 'Z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + '_', '-', '.', ',', ':', '/', '@': + buf.WriteString(string(b)) + default: + buf.WriteByte('\\') + buf.WriteString(string(b)) + } + } + } + return buf.String() +} diff --git a/cmd/micro/shellwords/shellwords_test.go b/cmd/micro/shellwords/shellwords_test.go new file mode 100644 index 00000000..594f9ef7 --- /dev/null +++ b/cmd/micro/shellwords/shellwords_test.go @@ -0,0 +1,229 @@ +package shellwords + +import ( + "os" + "reflect" + "testing" +) + +var testcases = []struct { + line string + expected []string +}{ + {`var --bar=baz`, []string{`var`, `--bar=baz`}}, + {`var --bar="baz"`, []string{`var`, `--bar=baz`}}, + {`var "--bar=baz"`, []string{`var`, `--bar=baz`}}, + {`var "--bar='baz'"`, []string{`var`, `--bar='baz'`}}, + {"var --bar=`baz`", []string{`var`, "--bar=`baz`"}}, + {`var "--bar=\"baz'"`, []string{`var`, `--bar="baz'`}}, + {`var "--bar=\'baz\'"`, []string{`var`, `--bar='baz'`}}, + {`var --bar='\'`, []string{`var`, `--bar=\`}}, + {`var "--bar baz"`, []string{`var`, `--bar baz`}}, + {`var --"bar baz"`, []string{`var`, `--bar baz`}}, + {`var --"bar baz"`, []string{`var`, `--bar baz`}}, +} + +func TestSimple(t *testing.T) { + for _, testcase := range testcases { + args, err := Parse(testcase.line) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(args, testcase.expected) { + t.Fatalf("Expected %#v, but %#v:", testcase.expected, args) + } + } +} + +func TestError(t *testing.T) { + _, err := Parse("foo '") + if err == nil { + t.Fatal("Should be an error") + } + _, err = Parse(`foo "`) + if err == nil { + t.Fatal("Should be an error") + } + + _, err = Parse("foo `") + if err == nil { + t.Fatal("Should be an error") + } +} + +func TestLastSpace(t *testing.T) { + args, err := Parse("foo bar\\ ") + if err != nil { + t.Fatal(err) + } + if len(args) != 2 { + t.Fatal("Should have two elements") + } + if args[0] != "foo" { + t.Fatal("1st element should be `foo`") + } + if args[1] != "bar " { + t.Fatal("1st element should be `bar `") + } +} + +func TestBacktick(t *testing.T) { + goversion, err := shellRun("go version") + if err != nil { + t.Fatal(err) + } + + parser := NewParser() + parser.ParseBacktick = true + args, err := parser.Parse("echo `go version`") + if err != nil { + t.Fatal(err) + } + expected := []string{"echo", goversion} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } + + args, err = parser.Parse(`echo $(echo foo)`) + if err != nil { + t.Fatal(err) + } + expected = []string{"echo", "foo"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } + + parser.ParseBacktick = false + args, err = parser.Parse(`echo $(echo "foo")`) + expected = []string{"echo", `$(echo "foo")`} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } + args, err = parser.Parse("echo $(`echo1)") + if err != nil { + t.Fatal(err) + } + expected = []string{"echo", "$(`echo1)"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } +} + +func TestBacktickError(t *testing.T) { + parser := NewParser() + parser.ParseBacktick = true + _, err := parser.Parse("echo `go Version`") + if err == nil { + t.Fatal("Should be an error") + } + expected := "exit status 2:go: unknown subcommand \"Version\"\nRun 'go help' for usage.\n" + if expected != err.Error() { + t.Fatalf("Expected %q, but %q", expected, err.Error()) + } + _, err = parser.Parse(`echo $(echo1)`) + if err == nil { + t.Fatal("Should be an error") + } + _, err = parser.Parse(`echo $(echo1`) + if err == nil { + t.Fatal("Should be an error") + } + _, err = parser.Parse(`echo $ (echo1`) + if err == nil { + t.Fatal("Should be an error") + } + _, err = parser.Parse(`echo (echo1`) + if err == nil { + t.Fatal("Should be an error") + } + _, err = parser.Parse(`echo )echo1`) + if err == nil { + t.Fatal("Should be an error") + } +} + +func TestEnv(t *testing.T) { + os.Setenv("FOO", "bar") + + parser := NewParser() + parser.ParseEnv = true + args, err := parser.Parse("echo $FOO") + if err != nil { + t.Fatal(err) + } + expected := []string{"echo", "bar"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } +} + +func TestNoEnv(t *testing.T) { + parser := NewParser() + parser.ParseEnv = true + args, err := parser.Parse("echo $BAR") + if err != nil { + t.Fatal(err) + } + expected := []string{"echo", ""} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } +} + +func TestDupEnv(t *testing.T) { + os.Setenv("FOO", "bar") + os.Setenv("FOO_BAR", "baz") + + parser := NewParser() + parser.ParseEnv = true + args, err := parser.Parse("echo $$FOO$") + if err != nil { + t.Fatal(err) + } + expected := []string{"echo", "$bar$"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } + + args, err = parser.Parse("echo $${FOO_BAR}$") + if err != nil { + t.Fatal(err) + } + expected = []string{"echo", "$baz$"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } +} + +func TestHaveMore(t *testing.T) { + parser := NewParser() + parser.ParseEnv = true + + line := "echo foo; seq 1 10" + args, err := parser.Parse(line) + if err != nil { + t.Fatalf(err.Error()) + } + expected := []string{"echo", "foo"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } + + if parser.Position == 0 { + t.Fatalf("Commands should be remaining") + } + + line = string([]rune(line)[parser.Position+1:]) + args, err = parser.Parse(line) + if err != nil { + t.Fatalf(err.Error()) + } + expected = []string{"seq", "1", "10"} + if !reflect.DeepEqual(args, expected) { + t.Fatalf("Expected %#v, but %#v:", expected, args) + } + + if parser.Position > 0 { + t.Fatalf("Commands should not be remaining") + } +} diff --git a/cmd/micro/shellwords/util_posix.go b/cmd/micro/shellwords/util_posix.go new file mode 100644 index 00000000..31bdda5a --- /dev/null +++ b/cmd/micro/shellwords/util_posix.go @@ -0,0 +1,22 @@ +// +build !windows + +package shellwords + +import ( + "errors" + "os" + "os/exec" + "strings" +) + +func shellRun(line string) (string, error) { + shell := os.Getenv("SHELL") + b, err := exec.Command(shell, "-c", line).Output() + if err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + b = eerr.Stderr + } + return "", errors.New(err.Error() + ":" + string(b)) + } + return strings.TrimSpace(string(b)), nil +} diff --git a/cmd/micro/shellwords/util_windows.go b/cmd/micro/shellwords/util_windows.go new file mode 100644 index 00000000..5e06565a --- /dev/null +++ b/cmd/micro/shellwords/util_windows.go @@ -0,0 +1,20 @@ +package shellwords + +import ( + "errors" + "os" + "os/exec" + "strings" +) + +func shellRun(line string) (string, error) { + shell := os.Getenv("COMSPEC") + b, err := exec.Command(shell, "/c", line).Output() + if err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + b = eerr.Stderr + } + return "", errors.New(err.Error() + ":" + string(b)) + } + return strings.TrimSpace(string(b)), nil +} diff --git a/cmd/micro/util.go b/cmd/micro/util.go index 3c1ef0d0..ac2cc1d0 100644 --- a/cmd/micro/util.go +++ b/cmd/micro/util.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "os" "path/filepath" "reflect" @@ -12,7 +11,6 @@ import ( "unicode/utf8" "github.com/mattn/go-runewidth" - "github.com/mattn/go-shellwords" homedir "github.com/mitchellh/go-homedir" ) @@ -277,55 +275,6 @@ func ShortFuncName(i interface{}) string { return strings.TrimPrefix(runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name(), "main.(*View).") } -// SplitCommandArgs separates multiple command arguments which may be quoted. -// The returned slice contains at least one string -func SplitCommandArgs(input string) ([]string, error) { - shellwords.ParseEnv = true - shellwords.ParseBacktick = true - return shellwords.Parse(input) -} - -// JoinCommandArgs joins multiple command arguments and quote the strings if needed. -func JoinCommandArgs(args ...string) string { - var buf bytes.Buffer - for i, w := range args { - if i != 0 { - buf.WriteByte(' ') - } - if w == "" { - buf.WriteString("''") - continue - } - - strBytes := []byte(w) - for _, b := range strBytes { - switch b { - case - 'a', 'b', 'c', 'd', 'e', 'f', 'g', - 'h', 'i', 'j', 'k', 'l', 'm', 'n', - 'o', 'p', 'q', 'r', 's', 't', 'u', - 'v', 'w', 'x', 'y', 'z', - 'A', 'B', 'C', 'D', 'E', 'F', 'G', - 'H', 'I', 'J', 'K', 'L', 'M', 'N', - 'O', 'P', 'Q', 'R', 'S', 'T', 'U', - 'V', 'W', 'X', 'Y', 'Z', - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - '_', '-', '.', ',', ':', '/', '@': - buf.WriteByte(b) - case '\n': - buf.WriteString("'\n'") - default: - buf.WriteByte('\\') - buf.WriteByte(b) - } - } - - // return buf.String() - // buf.WriteString(w) - } - return buf.String() -} - // ReplaceHome takes a path as input and replaces ~ at the start of the path with the user's // home directory. Does nothing if the path does not start with '~'. func ReplaceHome(path string) string { diff --git a/cmd/micro/vendor/github.com/mattn/go-shellwords b/cmd/micro/vendor/github.com/mattn/go-shellwords deleted file mode 160000 index 95c860c1..00000000 --- a/cmd/micro/vendor/github.com/mattn/go-shellwords +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 95c860c1895b21b58903abdd1d9c591560b0601c