diff --git a/_example/main.go b/_example/main.go index b99868e..575cd88 100644 --- a/_example/main.go +++ b/_example/main.go @@ -7,6 +7,7 @@ import ( "github.com/elk-language/go-prompt" cobraprompt "github.com/ionoscloudsdk/comptplus" "github.com/ionoscloudsdk/comptplus/_example/cmd" + "github.com/spf13/cobra" ) var advancedPrompt = &cobraprompt.CobraPrompt{ @@ -20,7 +21,7 @@ var advancedPrompt = &cobraprompt.CobraPrompt{ prompt.WithPrefix(">(^!^)> "), prompt.WithMaxSuggestion(10), }, - DynamicSuggestionsFunc: func(annotationValue string, document *prompt.Document) []prompt.Suggest { + DynamicSuggestionsFunc: func(_ *cobra.Command, annotationValue string, document *prompt.Document) []prompt.Suggest { if suggestions := cmd.GetFoodDynamic(annotationValue); suggestions != nil { return suggestions } diff --git a/comptplus.go b/comptplus.go index 011e39e..c001343 100644 --- a/comptplus.go +++ b/comptplus.go @@ -8,6 +8,7 @@ import ( "github.com/elk-language/go-prompt" istrings "github.com/elk-language/go-prompt/strings" + shellquote "github.com/kballard/go-shellquote" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -29,7 +30,7 @@ type CobraPrompt struct { // DynamicSuggestionsFunc will be executed if a command has CallbackAnnotation as an annotation. If it's included // the value will be provided to the DynamicSuggestionsFunc function. - DynamicSuggestionsFunc func(annotationValue string, document *prompt.Document) []prompt.Suggest + DynamicSuggestionsFunc func(cmd *cobra.Command, annotationValue string, document *prompt.Document) []prompt.Suggest // PersistFlagValues will persist flags. For example have verbose turned on every command. PersistFlagValues bool @@ -61,7 +62,7 @@ type CobraPrompt struct { // HookBefore is a hook that will be executed every time before a command is executed HookBefore func(cmd *cobra.Command, input string) error - // InArgsParser adds a custom parser for the command line arguments (default: strings.Fields) + // InArgsParser adds a custom parser for the command line arguments (default: shellquote.Split) InArgsParser func(args string) []string // SuggestionFilter will be uses when filtering suggestions as typing @@ -103,7 +104,6 @@ func (co *CobraPrompt) RunContext(ctx context.Context) { defValue := strings.Trim(flag.DefValue, "[]") defaultSlice := strings.Split(defValue, ",") err := sliceValue.Replace(defaultSlice) - if err != nil { // If there's an error parsing defaultSlice as a slice, try this workaround errShouldNeverHappenButWeAreProfessionals := sliceValue.Replace([]string{}) @@ -152,8 +152,9 @@ func (co *CobraPrompt) resetFlagsToDefault(cmd *cobra.Command) { func (co *CobraPrompt) executeCommand(ctx context.Context) func(string) { return func(input string) { args := co.parseInput(input) - os.Args = append([]string{os.Args[0]}, args...) - executedCmd, _, _ := co.RootCmd.Find(os.Args[1:]) + co.RootCmd.SetArgs(args) + + executedCmd, _, _ := co.RootCmd.Find(args) if err := co.HookBefore(executedCmd, input); err != nil { co.handleUserError(err) @@ -190,7 +191,11 @@ func (co *CobraPrompt) parseInput(input string) []string { if co.InArgsParser != nil { return co.InArgsParser(input) } - return strings.Fields(input) + + // Treat input as a shell command and split it into arguments + args, _ := shellquote.Split(input) + + return args } func (co *CobraPrompt) prepareCommands() { @@ -214,7 +219,8 @@ func (co *CobraPrompt) prepareCommands() { // findSuggestions generates command and flag suggestions for the prompt. func (co *CobraPrompt) findSuggestions(d prompt.Document) ([]prompt.Suggest, istrings.RuneNumber, istrings.RuneNumber) { command := co.RootCmd - args := strings.Fields(d.CurrentLine()) + args, _ := shellquote.Split(d.CurrentLine()) + w := d.GetWordBeforeCursor() endIndex := d.CurrentRuneIndex() @@ -270,6 +276,7 @@ func getFlagSuggestions(cmd *cobra.Command, co *CobraPrompt, d prompt.Document) cmd.LocalFlags().VisitAll(addFlags) cmd.InheritedFlags().VisitAll(addFlags) + return suggestions } @@ -291,7 +298,7 @@ func getDynamicSuggestions(cmd *cobra.Command, co *CobraPrompt, d prompt.Documen var suggestions []prompt.Suggest if dynamicSuggestionKey, ok := cmd.Annotations[DynamicSuggestionsAnnotation]; ok { if co.DynamicSuggestionsFunc != nil { - dynamicSuggestions := co.DynamicSuggestionsFunc(dynamicSuggestionKey, &d) + dynamicSuggestions := co.DynamicSuggestionsFunc(cmd, dynamicSuggestionKey, &d) suggestions = append(suggestions, dynamicSuggestions...) } } @@ -308,7 +315,8 @@ func getFlagValueSuggestions(cmd *cobra.Command, d prompt.Document, currentFlag } if compFunc, exists := cmd.GetFlagCompletionFunc(currentFlag); exists { - completions, _ := compFunc(cmd, strings.Fields(d.CurrentLine()), currentFlag) + args, _ := shellquote.Split(d.CurrentLine()) + completions, _ := compFunc(cmd, args, currentFlag) for _, completion := range completions { text, description, _ := strings.Cut(completion, "\t") suggestions = append(suggestions, prompt.Suggest{Text: text, Description: description}) @@ -321,7 +329,7 @@ func getFlagValueSuggestions(cmd *cobra.Command, d prompt.Document, currentFlag // - current flag // - whether the context is suitable for flag value suggestions. func getCurrentFlagAndValueContext(d prompt.Document, cmd *cobra.Command) (string, bool) { - prevWords := strings.Fields(d.TextBeforeCursor()) + prevWords, _ := shellquote.Split(d.TextBeforeCursor()) textBeforeCursor := d.TextBeforeCursor() hasSpaceSuffix := strings.HasSuffix(textBeforeCursor, " ") diff --git a/comptplus_test.go b/comptplus_test.go index f426f6f..acb1f13 100644 --- a/comptplus_test.go +++ b/comptplus_test.go @@ -4,25 +4,34 @@ import ( "testing" "github.com/elk-language/go-prompt" + "github.com/elk-language/go-prompt/strings" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) func TestFindSuggestions(t *testing.T) { - rootCmd := newTestCommand("root", "The root cmd") - getCmd := newTestCommand("get", "Get something") - getObjectCmd := newTestCommand("object", "Get the object") + // Command 1: root get thing getThingCmd := newTestCommand("thing", "The thing") + + // Command 2: root get food getFoodCmd := newTestCommand("food", "Get some food") getFoodCmd.PersistentFlags().StringP("name", "n", "John", "name of the person to get some food from") _ = getFoodCmd.RegisterFlagCompletionFunc("name", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"John", "Mary\tMarianne - John's Mother", "Anne"}, cobra.ShellCompDirectiveNoFileComp }) - rootCmd.AddCommand(getCmd) - getCmd.AddCommand(getObjectCmd, getThingCmd, getFoodCmd) + // Command 3: root get object + getObjectCmd := newTestCommand("object", "Get the object") getObjectCmd.Flags().BoolP("verbose", "v", false, "Verbose log") + // Command 4: root get + getCmd := newTestCommand("get", "Get something") + getCmd.AddCommand(getObjectCmd, getThingCmd, getFoodCmd) + + // Root: root + rootCmd := newTestCommand("root", "The root cmd") + rootCmd.AddCommand(getCmd) + cp := &CobraPrompt{ RootCmd: rootCmd, } @@ -72,7 +81,8 @@ func TestFindSuggestions(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { buf := prompt.NewBuffer() - buf.InsertText(test.input, true) + buf.InsertTextMoveCursor(test.input, strings.GetWidth(test.input), 0, true) + suggestions, _, _ := cp.findSuggestions(*buf.Document()) assert.Len(t, suggestions, len(test.expectedResults), "Incorrect number of suggestions") diff --git a/go.mod b/go.mod index 995d7cb..3ffdc2e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/elk-language/go-prompt v1.1.5 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum index 08c70fc..f914df2 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/vendor/github.com/kballard/go-shellquote/LICENSE b/vendor/github.com/kballard/go-shellquote/LICENSE new file mode 100644 index 0000000..a6d7731 --- /dev/null +++ b/vendor/github.com/kballard/go-shellquote/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2014 Kevin Ballard + +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/vendor/github.com/kballard/go-shellquote/README b/vendor/github.com/kballard/go-shellquote/README new file mode 100644 index 0000000..4d34e87 --- /dev/null +++ b/vendor/github.com/kballard/go-shellquote/README @@ -0,0 +1,36 @@ +PACKAGE + +package shellquote + import "github.com/kballard/go-shellquote" + + Shellquote provides utilities for joining/splitting strings using sh's + word-splitting rules. + +VARIABLES + +var ( + UnterminatedSingleQuoteError = errors.New("Unterminated single-quoted string") + UnterminatedDoubleQuoteError = errors.New("Unterminated double-quoted string") + UnterminatedEscapeError = errors.New("Unterminated backslash-escape") +) + + +FUNCTIONS + +func Join(args ...string) string + Join quotes each argument and joins them with a space. If passed to + /bin/sh, the resulting string will be split back into the original + arguments. + +func Split(input string) (words []string, err error) + Split splits a string according to /bin/sh's word-splitting rules. It + supports backslash-escapes, single-quotes, and double-quotes. Notably it + does not support the $'' style of quoting. It also doesn't attempt to + perform any other sort of expansion, including brace expansion, shell + expansion, or pathname expansion. + + If the given input has an unterminated quoted string or ends in a + backslash-escape, one of UnterminatedSingleQuoteError, + UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned. + + diff --git a/vendor/github.com/kballard/go-shellquote/doc.go b/vendor/github.com/kballard/go-shellquote/doc.go new file mode 100644 index 0000000..9445fa4 --- /dev/null +++ b/vendor/github.com/kballard/go-shellquote/doc.go @@ -0,0 +1,3 @@ +// Shellquote provides utilities for joining/splitting strings using sh's +// word-splitting rules. +package shellquote diff --git a/vendor/github.com/kballard/go-shellquote/quote.go b/vendor/github.com/kballard/go-shellquote/quote.go new file mode 100644 index 0000000..72a8cb3 --- /dev/null +++ b/vendor/github.com/kballard/go-shellquote/quote.go @@ -0,0 +1,102 @@ +package shellquote + +import ( + "bytes" + "strings" + "unicode/utf8" +) + +// Join quotes each argument and joins them with a space. +// If passed to /bin/sh, the resulting string will be split back into the +// original arguments. +func Join(args ...string) string { + var buf bytes.Buffer + for i, arg := range args { + if i != 0 { + buf.WriteByte(' ') + } + quote(arg, &buf) + } + return buf.String() +} + +const ( + specialChars = "\\'\"`${[|&;<>()*?!" + extraSpecialChars = " \t\n" + prefixChars = "~" +) + +func quote(word string, buf *bytes.Buffer) { + // We want to try to produce a "nice" output. As such, we will + // backslash-escape most characters, but if we encounter a space, or if we + // encounter an extra-special char (which doesn't work with + // backslash-escaping) we switch over to quoting the whole word. We do this + // with a space because it's typically easier for people to read multi-word + // arguments when quoted with a space rather than with ugly backslashes + // everywhere. + origLen := buf.Len() + + if len(word) == 0 { + // oops, no content + buf.WriteString("''") + return + } + + cur, prev := word, word + atStart := true + for len(cur) > 0 { + c, l := utf8.DecodeRuneInString(cur) + cur = cur[l:] + if strings.ContainsRune(specialChars, c) || (atStart && strings.ContainsRune(prefixChars, c)) { + // copy the non-special chars up to this point + if len(cur) < len(prev) { + buf.WriteString(prev[0 : len(prev)-len(cur)-l]) + } + buf.WriteByte('\\') + buf.WriteRune(c) + prev = cur + } else if strings.ContainsRune(extraSpecialChars, c) { + // start over in quote mode + buf.Truncate(origLen) + goto quote + } + atStart = false + } + if len(prev) > 0 { + buf.WriteString(prev) + } + return + +quote: + // quote mode + // Use single-quotes, but if we find a single-quote in the word, we need + // to terminate the string, emit an escaped quote, and start the string up + // again + inQuote := false + for len(word) > 0 { + i := strings.IndexRune(word, '\'') + if i == -1 { + break + } + if i > 0 { + if !inQuote { + buf.WriteByte('\'') + inQuote = true + } + buf.WriteString(word[0:i]) + } + word = word[i+1:] + if inQuote { + buf.WriteByte('\'') + inQuote = false + } + buf.WriteString("\\'") + } + if len(word) > 0 { + if !inQuote { + buf.WriteByte('\'') + } + buf.WriteString(word) + buf.WriteByte('\'') + } +} diff --git a/vendor/github.com/kballard/go-shellquote/unquote.go b/vendor/github.com/kballard/go-shellquote/unquote.go new file mode 100644 index 0000000..b1b13da --- /dev/null +++ b/vendor/github.com/kballard/go-shellquote/unquote.go @@ -0,0 +1,156 @@ +package shellquote + +import ( + "bytes" + "errors" + "strings" + "unicode/utf8" +) + +var ( + UnterminatedSingleQuoteError = errors.New("Unterminated single-quoted string") + UnterminatedDoubleQuoteError = errors.New("Unterminated double-quoted string") + UnterminatedEscapeError = errors.New("Unterminated backslash-escape") +) + +var ( + splitChars = " \n\t" + singleChar = '\'' + doubleChar = '"' + escapeChar = '\\' + doubleEscapeChars = "$`\"\n\\" +) + +// Split splits a string according to /bin/sh's word-splitting rules. It +// supports backslash-escapes, single-quotes, and double-quotes. Notably it does +// not support the $'' style of quoting. It also doesn't attempt to perform any +// other sort of expansion, including brace expansion, shell expansion, or +// pathname expansion. +// +// If the given input has an unterminated quoted string or ends in a +// backslash-escape, one of UnterminatedSingleQuoteError, +// UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned. +func Split(input string) (words []string, err error) { + var buf bytes.Buffer + words = make([]string, 0) + + for len(input) > 0 { + // skip any splitChars at the start + c, l := utf8.DecodeRuneInString(input) + if strings.ContainsRune(splitChars, c) { + input = input[l:] + continue + } else if c == escapeChar { + // Look ahead for escaped newline so we can skip over it + next := input[l:] + if len(next) == 0 { + err = UnterminatedEscapeError + return + } + c2, l2 := utf8.DecodeRuneInString(next) + if c2 == '\n' { + input = next[l2:] + continue + } + } + + var word string + word, input, err = splitWord(input, &buf) + if err != nil { + return + } + words = append(words, word) + } + return +} + +func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { + buf.Reset() + +raw: + { + cur := input + for len(cur) > 0 { + c, l := utf8.DecodeRuneInString(cur) + cur = cur[l:] + if c == singleChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto single + } else if c == doubleChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto double + } else if c == escapeChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto escape + } else if strings.ContainsRune(splitChars, c) { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + return buf.String(), cur, nil + } + } + if len(input) > 0 { + buf.WriteString(input) + input = "" + } + goto done + } + +escape: + { + if len(input) == 0 { + return "", "", UnterminatedEscapeError + } + c, l := utf8.DecodeRuneInString(input) + if c == '\n' { + // a backslash-escaped newline is elided from the output entirely + } else { + buf.WriteString(input[:l]) + } + input = input[l:] + } + goto raw + +single: + { + i := strings.IndexRune(input, singleChar) + if i == -1 { + return "", "", UnterminatedSingleQuoteError + } + buf.WriteString(input[0:i]) + input = input[i+1:] + goto raw + } + +double: + { + cur := input + for len(cur) > 0 { + c, l := utf8.DecodeRuneInString(cur) + cur = cur[l:] + if c == doubleChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto raw + } else if c == escapeChar { + // bash only supports certain escapes in double-quoted strings + c2, l2 := utf8.DecodeRuneInString(cur) + cur = cur[l2:] + if strings.ContainsRune(doubleEscapeChars, c2) { + buf.WriteString(input[0 : len(input)-len(cur)-l-l2]) + if c2 == '\n' { + // newline is special, skip the backslash entirely + } else { + buf.WriteRune(c2) + } + input = cur + } + } + } + return "", "", UnterminatedDoubleQuoteError + } + +done: + return buf.String(), input, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 24f398e..b4a089c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -11,6 +11,9 @@ github.com/elk-language/go-prompt/term # github.com/inconshreveable/mousetrap v1.1.0 ## explicit; go 1.18 github.com/inconshreveable/mousetrap +# github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 +## explicit +github.com/kballard/go-shellquote # github.com/kr/pretty v0.1.0 ## explicit # github.com/mattn/go-colorable v0.1.7