From 3db68bcb0e55c40ad8627c65e169fc12240e5212 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Tue, 11 Aug 2020 13:44:10 -0700 Subject: [PATCH] Bot: Allow hanging quotes if command has a custom parser --- bot/arguments.go | 2 + bot/ctx_call.go | 28 ++++++++--- bot/extras/shellwords/shellwords.go | 64 +++++++++++++++--------- bot/extras/shellwords/shellwords_test.go | 54 ++++++++++++++++++++ 4 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 bot/extras/shellwords/shellwords_test.go diff --git a/bot/arguments.go b/bot/arguments.go index cb1fb51..b0abcbe 100644 --- a/bot/arguments.go +++ b/bot/arguments.go @@ -72,6 +72,8 @@ func (r ArgumentParts) Usage() string { // CustomParser has a CustomParse method, which would be passed in the full // message content with the prefix and command trimmed. This is used // for commands that require more advanced parsing than the default parser. +// +// Keep in mind that this does not trim arguments before it. type CustomParser interface { CustomParse(arguments string) error } diff --git a/bot/ctx_call.go b/bot/ctx_call.go index 120d747..b9eab5d 100644 --- a/bot/ctx_call.go +++ b/bot/ctx_call.go @@ -125,13 +125,12 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl } // parse arguments - parts, err := ctx.ParseArgs(content) - if err != nil { - return errors.Wrap(err, "failed to parse command") - } + parts, parseErr := ctx.ParseArgs(content) + // We're not checking parse errors yet, as raw arguments may be able to + // ignore it. if len(parts) == 0 { - return nil // ??? + return parseErr } // Find the command and subcommand. @@ -258,6 +257,10 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl // If the argument wants all arguments in string: case last.custom != nil: + // Ignore parser errors. This allows custom commands sliced away to + // have erroneous hanging quotes. + parseErr = nil + // Manual string seeking is a must here. This is because the string // could contain multiple whitespaces, and the parser would not // count them. @@ -268,9 +271,17 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl } // Seek to the string. - if i := strings.Index(content, seekTo); i > -1 { + var i = strings.Index(content, seekTo) + // Edge case if the subcommand is the same as the command. + if cmd.Command == sub.Command { + // Seek again past the command. + i = strings.Index(content[i+len(seekTo):], seekTo) + } + + if i > -1 { // Seek past the substring. i += len(seekTo) + content = strings.TrimSpace(content[i:]) } @@ -292,6 +303,11 @@ func (ctx *Context) callMessageCreate(mc *gateway.MessageCreateEvent, value refl argv = append(argv, v) } + // Check for parsing errors after parsing arguments. + if parseErr != nil { + return parseErr + } + Call: // call the function and parse the error return value v, err := cmd.call(value, argv...) diff --git a/bot/extras/shellwords/shellwords.go b/bot/extras/shellwords/shellwords.go index dc3b164..1d8d733 100644 --- a/bot/extras/shellwords/shellwords.go +++ b/bot/extras/shellwords/shellwords.go @@ -1,19 +1,31 @@ package shellwords import ( - "errors" + "fmt" + "strings" ) type ErrParse struct { - Line string - Position string + Position int + ErrorStart, + ErrorPart, + ErrorEnd string } +func (e ErrParse) Error() string { + return fmt.Sprintf( + "Unexpected quote or escape: %s__%s__%s", + e.ErrorStart, e.ErrorPart, e.ErrorEnd, + ) +} + +// Parse parses the given text to a slice of words. func Parse(line string) ([]string, error) { var args []string - buf := "" var escaped, doubleQuoted, singleQuoted bool - backtick := "" + + var buf strings.Builder + buf.Grow(len(line)) got := false cursor := 0 @@ -22,14 +34,14 @@ func Parse(line string) ([]string, error) { for _, r := range runes { if escaped { - buf += string(r) + buf.WriteRune(r) escaped = false continue } if r == '\\' { if singleQuoted { - buf += string(r) + buf.WriteRune(r) } else { escaped = true } @@ -39,12 +51,11 @@ func Parse(line string) ([]string, error) { if isSpace(r) { switch { case singleQuoted, doubleQuoted: - buf += string(r) - backtick += string(r) + buf.WriteRune(r) case got: - cursor += len(buf) - args = append(args, buf) - buf = "" + cursor += buf.Len() + args = append(args, buf.String()) + buf.Reset() got = false } continue @@ -59,22 +70,28 @@ func Parse(line string) ([]string, error) { doubleQuoted = !doubleQuoted continue } - case '\'': + case '\'', '`': if !doubleQuoted { if singleQuoted { got = true } + + // // If this is a backtick, then write it. + // if r == '`' { + // buf.WriteByte('`') + // } + singleQuoted = !singleQuoted continue } } got = true - buf += string(r) + buf.WriteRune(r) } if got { - args = append(args, buf) + args = append(args, buf.String()) } if escaped || singleQuoted || doubleQuoted { @@ -83,18 +100,15 @@ func Parse(line string) ([]string, error) { pos = cursor + 5 start = string(runes[max(cursor-100, 0) : pos-1]) end = string(runes[pos+1 : min(cursor+100, len(runes))]) - part = "" + part = string(runes[max(pos-1, 0):min(len(runes), pos+2)]) ) - for i := pos - 1; i >= 0 && i < len(runes) && i < pos+2; i++ { - if runes[i] == '\\' { - part += "\\" - } - part += string(runes[i]) + return args, &ErrParse{ + Position: cursor, + ErrorStart: start, + ErrorPart: part, + ErrorEnd: end, } - - return nil, errors.New( - "Unexpected quote or escape: " + start + "__" + part + "__" + end) } return args, nil @@ -102,7 +116,7 @@ func Parse(line string) ([]string, error) { func isSpace(r rune) bool { switch r { - case ' ', '\t', '\r', '\n': + case ' ', '\t', '\r', '\n', ' ': return true } return false diff --git a/bot/extras/shellwords/shellwords_test.go b/bot/extras/shellwords/shellwords_test.go new file mode 100644 index 0000000..bde0859 --- /dev/null +++ b/bot/extras/shellwords/shellwords_test.go @@ -0,0 +1,54 @@ +package shellwords + +import ( + "reflect" + "testing" +) + +type wordsTest struct { + line string + args []string + doErr bool +} + +func TestParse(t *testing.T) { + var tests = []wordsTest{ + { + `this is a "test"`, + []string{"this", "is", "a", "test"}, + false, + }, + { + `hanging "quote`, + []string{"hanging", "quote"}, + true, + }, + { + `Hello, 世界`, + []string{"Hello,", "世界"}, + false, + }, + { + "this is `inline code`", + []string{"this", "is", "inline code"}, + false, + }, + { + "how about a ```go\npackage main\n```\ngo code?", + []string{"how", "about", "a", "go\npackage main\n", "go", "code?"}, + false, + }, + } + + for _, test := range tests { + w, err := Parse(test.line) + if err != nil && !test.doErr { + t.Errorf("Error at %q: %v", test.line, err) + continue + } + + if !reflect.DeepEqual(w, test.args) { + t.Errorf("Inequality:\n%#v !=\n%#v", w, test.args) + } + } +}