Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Readline management commands, fixes and enhancements to code. #27

Merged
merged 10 commits into from
Aug 16, 2023
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand All @@ -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 ./...
Expand Down
5 changes: 2 additions & 3 deletions commands/readline/bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions commands/readline/commands.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 21 additions & 9 deletions commands/readline/completers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
})
}

Expand Down
3 changes: 1 addition & 2 deletions commands/readline/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
5 changes: 2 additions & 3 deletions commands/readline/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
186 changes: 121 additions & 65 deletions tab-completer.go → completer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"regexp"
"strings"
"unicode"
"unicode/utf8"

"github.com/reeflective/readline"
Expand All @@ -20,32 +21,26 @@ 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))

// Tranfer all completion results to our readline shell 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.
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
)
Loading