diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 73a1b7f..1521716 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.20' + go-version: '1.21' - name: Build run: go build -v ./... @@ -42,7 +42,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.20' + go-version: '1.21' - name: Build run: go build -v ./... diff --git a/commands/readline/bind.go b/commands/readline/bind.go index 2af32de..a370360 100644 --- a/commands/readline/bind.go +++ b/commands/readline/bind.go @@ -6,11 +6,10 @@ import ( "os" "strings" - "github.com/rsteube/carapace" - "github.com/spf13/cobra" - "github.com/reeflective/readline" "github.com/reeflective/readline/inputrc" + "github.com/rsteube/carapace" + "github.com/spf13/cobra" ) // Bind returns a command named `bind`, for manipulating readline keymaps and bindings. diff --git a/commands/readline/commands.go b/commands/readline/commands.go index cb89c6c..e3ec1bf 100644 --- a/commands/readline/commands.go +++ b/commands/readline/commands.go @@ -1,9 +1,8 @@ package readline import ( - "github.com/spf13/cobra" - "github.com/reeflective/readline" + "github.com/spf13/cobra" ) // Commands returns a command named `readline`, with subcommands dedicated diff --git a/commands/readline/completers.go b/commands/readline/completers.go index fa3ee06..c384db2 100644 --- a/commands/readline/completers.go +++ b/commands/readline/completers.go @@ -20,12 +20,12 @@ package readline import ( "fmt" - - "github.com/rsteube/carapace" - "github.com/spf13/cobra" + "strings" "github.com/reeflective/readline" "github.com/reeflective/readline/inputrc" + "github.com/rsteube/carapace" + "github.com/spf13/cobra" ) func completeKeymaps(sh *readline.Shell, _ *cobra.Command) carapace.Action { @@ -60,22 +60,34 @@ func completeBindSequences(sh *readline.Shell, cmd *cobra.Command) carapace.Acti } // Make a list of all sequences bound to each command, with descriptions. - cmdBinds := make([]string, 0) - insertBinds := make([]string, 0) + var cmdBinds, insertBinds []string for key, bind := range binds { + val := inputrc.Escape(key) + if bind.Action == "self-insert" { - insertBinds = append(insertBinds, "\""+inputrc.Escape(key)+"\"") + insertBinds = append(insertBinds, val) } else { - cmdBinds = append(cmdBinds, "\""+inputrc.Escape(key)+"\"") + cmdBinds = append(cmdBinds, val) cmdBinds = append(cmdBinds, bind.Action) } } - return carapace.Batch( + // Build the list of bind sequences bompletions + completions := carapace.Batch( carapace.ActionValues(insertBinds...).Tag(fmt.Sprintf("self-insert binds (%s)", keymap)).Usage("sequence"), carapace.ActionValuesDescribed(cmdBinds...).Tag(fmt.Sprintf("non-insert binds (%s)", keymap)).Usage("sequence"), - ).ToA() + ).ToA().Suffix("\"") + + // We're lucky and be particularly cautious about completion here: + // Look for the current argument and check whether or not it's quoted. + // If yes, only include quotes at the end of the inserted value. + // If no quotes, include them in both. + if strings.HasPrefix(ctx.Value, "\"") || ctx.Value == "" { + completions = completions.Prefix("\"") + } + + return completions }) } diff --git a/commands/readline/export.go b/commands/readline/export.go index 67f70e9..0030f15 100644 --- a/commands/readline/export.go +++ b/commands/readline/export.go @@ -23,10 +23,9 @@ import ( "sort" "strings" - "github.com/spf13/cobra" - "github.com/reeflective/readline" "github.com/reeflective/readline/inputrc" + "github.com/spf13/cobra" ) const ( diff --git a/commands/readline/set.go b/commands/readline/set.go index ee0a2da..39c1e51 100644 --- a/commands/readline/set.go +++ b/commands/readline/set.go @@ -5,14 +5,13 @@ import ( "strconv" "strings" + "github.com/reeflective/readline" + "github.com/reeflective/readline/inputrc" "github.com/rsteube/carapace" "github.com/rsteube/carapace/pkg/style" "github.com/spf13/cobra" "golang.org/x/exp/maps" "golang.org/x/exp/slices" - - "github.com/reeflective/readline" - "github.com/reeflective/readline/inputrc" ) var ( diff --git a/tab-completer.go b/completer.go similarity index 65% rename from tab-completer.go rename to completer.go index 5418bdf..374c6ea 100644 --- a/tab-completer.go +++ b/completer.go @@ -7,6 +7,7 @@ import ( "os" "regexp" "strings" + "unicode" "unicode/utf8" "github.com/reeflective/readline" @@ -20,17 +21,12 @@ func (c *Console) complete(line []rune, pos int) readline.Completions { // Split the line as shell words, only using // what the right buffer (up to the cursor) - rbuffer := line[:pos] - args, prefix := splitArgs(rbuffer) - args = sanitizeArgs(rbuffer, args) + args, prefixComp, prefixLine := splitArgs(line, pos) // Prepare arguments for the carapace completer // (we currently need those two dummies for avoiding a panic). args = append([]string{"examples", "_carapace"}, args...) - // Regenerate a new instance of the commands. - // menu.hideFilteredCommands(cmd) - // Call the completer with our current command context. values, meta := carapace.Complete(menu.Command, args, c.completeCommands(menu)) @@ -38,14 +34,13 @@ func (c *Console) complete(line []rune, pos int) readline.Completions { raw := make([]readline.Completion, len(values)) for idx, val := range values { - value := readline.Completion{ - Value: val.Value, + raw[idx] = readline.Completion{ + Value: unescapeValue(prefixComp, prefixLine, val.Value), Display: val.Display, Description: val.Description, Style: val.Style, Tag: val.Tag, } - raw[idx] = value } // Assign both completions and command/flags/args usage strings. @@ -58,66 +53,19 @@ func (c *Console) complete(line []rune, pos int) readline.Completions { comps = comps.NoSpace([]rune(meta.Nospace.String())...) } + // Other status/error messages + for _, msg := range meta.Messages.Get() { + comps = comps.Merge(readline.CompleteMessage(msg)) + } + // If we have a quote/escape sequence unaccounted // for in our completions, add it to all of them. - if prefix != "" { - comps = comps.Prefix(prefix) - } + comps = comps.Prefix(prefixComp) + comps.PREFIX = prefixLine return comps } -func splitArgs(line []rune) (args []string, prefix string) { - // Remove all colors from the string - line = []rune(strip(string(line))) - - // Split the line as shellwords, return them if all went fine. - args, remain, err := splitCompWords(string(line)) - if err == nil { - return args, remain - } - - // If we had an error, it's because we have an unterminated quote/escape sequence. - // In this case we split the remainder again, as the completer only ever considers - // words as space-separated chains of characters. - if errors.Is(err, errUnterminatedDoubleQuote) { - remain = strings.Trim(remain, "\"") - prefix = "\"" - } else if errors.Is(err, errUnterminatedSingleQuote) { - remain = strings.Trim(remain, "'") - prefix = "'" - } - - args = append(args, strings.Split(remain, " ")...) - - return -} - -func sanitizeArgs(rbuffer []rune, args []string) (sanitized []string) { - // Like in classic system shells, we need to add an empty - // argument if the last character is a space: the args - // returned from the previous call don't account for it. - if strings.HasSuffix(string(rbuffer), " ") || len(args) == 0 { - args = append(args, "") - } else if strings.HasSuffix(string(rbuffer), "\n") { - args = append(args, "") - } - - if len(args) == 0 { - return - } - - sanitized = args[:len(args)-1] - last := args[len(args)-1] - - // The last word should not comprise newlines. - last = strings.ReplaceAll(last, "\n", " ") - last = strings.ReplaceAll(last, "\\ ", " ") - sanitized = append(sanitized, last) - - return sanitized -} - // Regenerate commands and apply any filters. func (c *Console) completeCommands(menu *Menu) func() { commands := func() { @@ -175,6 +123,109 @@ func (c *Console) defaultStyleConfig() { style.Set("carapace.FlagOptArg", "bright-white") } +// splitArgs splits the line in valid words, prepares them in various ways before calling +// the completer with them, and also determines which parts of them should be used as +// prefixes, in the completions and/or in the line. +func splitArgs(line []rune, pos int) (args []string, prefixComp, prefixLine string) { + line = line[:pos] + + // Remove all colors from the string + line = []rune(strip(string(line))) + + // Split the line as shellwords, return them if all went fine. + args, remain, err := splitCompWords(string(line)) + + // We might have either no error and args, or no error and + // the cursor ready to complete a new word (last character + // in line is a space). + // In some of those cases we append a single dummy argument + // for the completer to understand we want a new word comp. + mustComplete, args, remain := mustComplete(line, args, remain, err) + if mustComplete { + return sanitizeArgs(args), "", remain + } + + // But the completion candidates themselves might need slightly + // different prefixes, for an optimal completion experience. + arg, prefixComp, prefixLine := adjustQuotedPrefix(remain, err) + + // The remainder is everything following the open charater. + // Pass it as is to the carapace completion engine. + args = append(args, arg) + + return sanitizeArgs(args), prefixComp, prefixLine +} + +func mustComplete(line []rune, args []string, remain string, err error) (bool, []string, string) { + dummyArg := "" + + // Empty command line, complete the root command. + if len(args) == 0 || len(line) == 0 { + return true, append(args, dummyArg), remain + } + + // If we have an error, we must handle it later. + if err != nil { + return false, args, remain + } + + lastChar := line[len(line)-1] + + // No remain and a trailing space means we want to complete + // for the next word, except when this last space was escaped. + if remain == "" && unicode.IsSpace(lastChar) { + if strings.HasSuffix(string(line), "\\ ") { + return true, args, args[len(args)-1] + } + + return true, append(args, dummyArg), remain + } + + // Else there is a character under the cursor, which means we are + // in the middle/at the end of a posentially completed word. + return true, args, remain +} + +func adjustQuotedPrefix(remain string, err error) (arg, comp, line string) { + arg = remain + + switch { + case errors.Is(err, errUnterminatedDoubleQuote): + comp = "\"" + line = comp + arg + case errors.Is(err, errUnterminatedSingleQuote): + comp = "'" + line = comp + arg + case errors.Is(err, errUnterminatedEscape): + arg = strings.ReplaceAll(arg, "\\", "") + } + + return arg, comp, line +} + +// sanitizeArg unescapes a restrained set of characters. +func sanitizeArgs(args []string) (sanitized []string) { + for _, arg := range args { + arg = replacer.Replace(arg) + sanitized = append(sanitized, arg) + } + + return sanitized +} + +// when the completer has returned us some completions, we sometimes +// needed to post-process them a little before passing them to our shell. +func unescapeValue(prefixComp, prefixLine, val string) string { + quoted := strings.HasPrefix(prefixLine, "\"") || + strings.HasPrefix(prefixLine, "'") + + if quoted { + val = strings.ReplaceAll(val, "\\ ", " ") + } + + return val +} + // split has been copied from go-shellquote and slightly modified so as to also // return the remainder when the parsing failed because of an unterminated quote. func splitCompWords(input string) (words []string, remainder string, err error) { @@ -206,8 +257,7 @@ func splitCompWords(input string) (words []string, remainder string, err error) word, input, err = splitCompWord(input, &buf) if err != nil { - remainder = input - return + return words, word + input, err } words = append(words, word) @@ -320,3 +370,9 @@ var re = regexp.MustCompile(ansi) func strip(str string) string { return re.ReplaceAllString(str, "") } + +var replacer = strings.NewReplacer( + "\n", ` `, + "\t", ` `, + "\\ ", " ", // User-escaped spaces in words. +) diff --git a/go.mod b/go.mod index df06406..d9de271 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ module github.com/reeflective/console -go 1.20 +go 1.21 require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/reeflective/readline v1.0.8 - github.com/rsteube/carapace v0.36.3 + github.com/reeflective/readline v1.0.9 + github.com/rsteube/carapace v0.43.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 + golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 ) require ( @@ -15,11 +16,10 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect - golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/term v0.8.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/rsteube/carapace v0.36.3 => github.com/reeflective/carapace v0.25.2-0.20230602202234-e8d757e458ca +replace github.com/rsteube/carapace v0.43.0 => github.com/reeflective/carapace v0.25.2-0.20230816093630-a30f5184fa0d diff --git a/go.sum b/go.sum index cae8f7d..adab06f 100644 --- a/go.sum +++ b/go.sum @@ -14,10 +14,10 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/reeflective/carapace v0.25.2-0.20230602202234-e8d757e458ca h1:tD797h1qmNtS/2z6Y7EtIg7OXEDaoSuULsUoksEepmQ= -github.com/reeflective/carapace v0.25.2-0.20230602202234-e8d757e458ca/go.mod h1:jkLt41Ne2TD2xPuMdX/2O05Smhy8vMgG7O2TYvC0yOc= -github.com/reeflective/readline v1.0.8 h1:VuDGI82lAwl1H5by+hpW4OQgM+9ikh6EuOySQUGP3sI= -github.com/reeflective/readline v1.0.8/go.mod h1:5JgnHb/ZCvp/6RUA59HEansPBxWTkyBO4hJ5LL9Fp1Y= +github.com/reeflective/carapace v0.25.2-0.20230816093630-a30f5184fa0d h1:RK0OaQs+3CMJnfXc5SNEg+Kbu4A2AVljPuG5/HcaUdM= +github.com/reeflective/carapace v0.25.2-0.20230816093630-a30f5184fa0d/go.mod h1:jkLt41Ne2TD2xPuMdX/2O05Smhy8vMgG7O2TYvC0yOc= +github.com/reeflective/readline v1.0.9 h1:ZA+V4HIWonwn8B4gUaaKwPtBogch19qgdk1I+hqULdk= +github.com/reeflective/readline v1.0.9/go.mod h1:mcD0HxNVJVteVwDm9caXKg52nQACVyfh8EyuBmgVlzY= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= diff --git a/syntax-highlighter.go b/highlighter.go similarity index 100% rename from syntax-highlighter.go rename to highlighter.go diff --git a/menu.go b/menu.go index c3564e9..2159719 100644 --- a/menu.go +++ b/menu.go @@ -192,15 +192,17 @@ func (m *Menu) Printf(msg string, args ...any) (n int, err error) { return m.console.Printf(buf) } -// ErrUnavailableCommand checks if a target command is marked as filtered as per the console -// application registered/and or active filters (added with console.Hide/ShowCommand()), and -// if yes, returns a template-formatted error message showing the list of incompatible filters. -func (m *Menu) ErrUnavailableCommand(target *cobra.Command) error { - if target == nil { +// CheckIsAvailable checks if a target command is marked as filtered +// by the console application registered/and or active filters (added +// with console.Hide/ShowCommand()). +// If filtered, returns a template-formatted error message showing the +// list of incompatible filters. If not filtered, no error is returned. +func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error { + if cmd == nil { return nil } - filters := m.ActiveFiltersFor(target) + filters := m.ActiveFiltersFor(cmd) if len(filters) == 0 { return nil } @@ -209,7 +211,7 @@ func (m *Menu) ErrUnavailableCommand(target *cobra.Command) error { err := tmpl(&bufErr, m.errorFilteredCommandTemplate(filters), map[string]interface{}{ "menu": m, - "cmd": target, + "cmd": cmd, "filters": filters, }) diff --git a/run.go b/run.go index 26bd40a..b14a7df 100644 --- a/run.go +++ b/run.go @@ -89,47 +89,25 @@ func (c *Console) Start() error { } } -// ExecuteOnce is a wrapper around the classic one-time cobra command execution. -// This call is thus blocking during the entire parsing and execution process -// of a command-line. -// -// This function should be useful if you have trees of commands that can -// be executed both in closed-loop applications or in a one-off exec style. -// Normally, most commands should, if your command behavior/API has no magic. -// -// The command line (os.Args) is matched against the currently active menu. -// Be sure to set and verify this menu before calling this function. -// This function also does not print any application logo. -func (c *Console) ExecuteOnce() error { - // Always ensure we work with the active menu, with freshly - // generated commands, bound prompts and some other things. - menu := c.activeMenu() - menu.resetPreRun() - - c.printed = false - - if c.NewlineBefore { - fmt.Println() - } - - // Run user-provided pre-run line hooks, - // which may modify the input line args. - args, err := c.runLineHooks(os.Args) - if err != nil { - return fmt.Errorf("line error: %s", err.Error()) - } +// RunCommandArgs is a convenience function to run a command line in a given menu. +// After running, the menu's commands are reset, and the prompts reloaded, therefore +// mostly mimicking the behavior that is the one of the normal readline/run/readline +// workflow. +// Although state segregation is a priority for this library to be ensured as much +// as possible, you should be cautious when using this function to run commands. +func (m *Menu) RunCommandArgs(args []string) (err error) { + // The menu used and reset is the active menu. + // Prepare its output buffer for the command. + m.resetPreRun() - // Run all pre-run hooks and the command itself - // Don't check the error: if its a cobra error, - // the library user is responsible for setting - // the cobra behavior. - // If it's an interrupt, we take care of it. - return c.execute(menu, args, false) + // Run the command and associated helpers. + return m.console.execute(m, args, !m.console.isExecuting) } -// RunCommand is a convenience function to run a command in a given menu. -// After running, the menu commands are reset, and the prompts reloaded. -func (m *Menu) RunCommand(line string) (err error) { +// RunCommandLine is the equivalent of menu.RunCommandArgs(), but accepts +// an unsplit command line to execute. This line is split and processed in +// *sh-compliant form, identically to how lines are in normal console usage. +func (m *Menu) RunCommandLine(line string) (err error) { if len(line) == 0 { return } @@ -140,12 +118,7 @@ func (m *Menu) RunCommand(line string) (err error) { return fmt.Errorf("line error: %w", err) } - // The menu used and reset is the active menu. - // Prepare its output buffer for the command. - m.resetPreRun() - - // Run the command and associated helpers. - return m.console.execute(m, args, !m.console.isExecuting) + return m.RunCommandArgs(args) } // execute - The user has entered a command input line, the arguments have been processed: @@ -173,7 +146,7 @@ func (c *Console) execute(menu *Menu, args []string, async bool) (err error) { // Find the target command: if this command is filtered, don't run it. target, _, _ := cmd.Find(args) - if err := menu.ErrUnavailableCommand(target); err != nil { + if err := menu.CheckIsAvailable(target); err != nil { return err }