diff --git a/README.md b/README.md index 0e69b3d..45bab82 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,3 @@ The library is in a pre-release candidate status: Please open a PR or an issue if you wish to bring enhancements to it. Other contributions, as well as bug fixes and reviews are also welcome. - - -## Possible Improvements - -The following is a currently moving list of possible enhancements to be made in order to reach `v1.0`: -- [ ] Ensure to the best extent possible a thread-safe access to the command API. -- [ ] Clearer integration/alignment of the various I/O references between raw readline and commands. -- [ ] Clearer and sane model for asynchronous control/cancel of commands (with OnKillRun in cobra) -- [ ] Test suite for most important or risky code paths. diff --git a/command.go b/command.go index a33a7c5..d3858cd 100644 --- a/command.go +++ b/command.go @@ -2,7 +2,6 @@ package console import ( "github.com/spf13/cobra" - "github.com/spf13/pflag" ) const ( @@ -74,39 +73,3 @@ next: c.filters = updated } - -// resetFlagsDefaults resets all flags to their default values. -// -// Slice flags accumulate per execution (and do not reset), -// -// so we must reset them manually. -// -// Example: -// -// Given cmd.Flags().StringSlice("comment", nil, "") -// If you run a command with --comment "a" --comment "b" you will get -// the expected [a, b] slice. -// -// If you run a command again with no --comment flags, you will get -// [a, b] again instead of an empty slice. -// -// If you run the command again with --comment "c" --comment "d" flags, -// you will get [a, b, c, d] instead of just [c, d]. -func resetFlagsDefaults(target *cobra.Command) { - target.Flags().VisitAll(func(flag *pflag.Flag) { - flag.Changed = false - switch value := flag.Value.(type) { - case pflag.SliceValue: - var res []string - - if len(flag.DefValue) > 0 && flag.DefValue != "[]" { - res = append(res, flag.DefValue) - } - - value.Replace(res) - - default: - flag.Value.Set(flag.DefValue) - } - }) -} diff --git a/completer.go b/completer.go index 7d72613..f8bf763 100644 --- a/completer.go +++ b/completer.go @@ -1,24 +1,18 @@ package console import ( - "bytes" - "errors" - "fmt" - "os" - "regexp" "strings" - "unicode" - "unicode/utf8" - - "github.com/carapace-sh/carapace" - "github.com/carapace-sh/carapace/pkg/style" - completer "github.com/carapace-sh/carapace/pkg/x" - "github.com/carapace-sh/carapace/pkg/xdg" "github.com/reeflective/readline" + "github.com/rsteube/carapace" + "github.com/rsteube/carapace/pkg/style" + completer "github.com/rsteube/carapace/pkg/x" + + "github.com/reeflective/console/internal/completion" + "github.com/reeflective/console/internal/line" ) -func (c *Console) complete(line []rune, pos int) readline.Completions { +func (c *Console) complete(input []rune, pos int) readline.Completions { menu := c.activeMenu() // Ensure the carapace library is called so that the function @@ -27,7 +21,7 @@ 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) - args, prefixComp, prefixLine := splitArgs(line, pos) + args, prefixComp, prefixLine := completion.SplitArgs(input, pos) // Prepare arguments for the carapace completer // (we currently need those two dummies for avoiding a panic). @@ -40,12 +34,12 @@ func (c *Console) complete(line []rune, pos int) readline.Completions { // with everything it contains, regardless of errors. raw := make([]readline.Completion, len(completions.Values)) - for idx, val := range completions.Values.Decolor() { + for idx, val := range completions.Values { raw[idx] = readline.Completion{ - Value: unescapeValue(prefixComp, prefixLine, val.Value), + Value: line.UnescapeValue(prefixComp, prefixLine, val.Value), Display: val.Display, Description: val.Description, - Style: val.Style, + Style: style.SGR(val.Style), Tag: val.Tag, } @@ -88,6 +82,9 @@ func (c *Console) complete(line []rune, pos int) readline.Completions { return comps } +// justifyCommandComps justifies the descriptions for all commands in all groups +// to the same level, for prettiness. Also, removes any coloring from them, as currently, +// the carapace engine does add coloring to each group, and we don't want this. func (c *Console) justifyCommandComps(comps readline.Completions) readline.Completions { justified := []string{} @@ -97,6 +94,7 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl } justified = append(justified, comp.Tag) + comp.Style = "" // Remove command coloring return comp }) @@ -108,280 +106,32 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl return comps } -func (c *Console) defaultStyleConfig() { - // If carapace config file is found, just return. - if dir, err := xdg.UserConfigDir(); err == nil { - _, err := os.Stat(fmt.Sprintf("%v/carapace/styles.json", dir)) - if err == nil { - return - } - } - - // Overwrite all default styles for color - for i := 1; i < 13; i++ { - styleStr := fmt.Sprintf("carapace.Highlight%d", i) - style.Set(styleStr, "bright-white") - } - - // Overwrite all default styles for flags - style.Set("carapace.FlagArg", "bright-white") - style.Set("carapace.FlagMultiArg", "bright-white") - style.Set("carapace.FlagNoArg", "bright-white") - 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. +// highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console. +func (c *Console) highlightSyntax(input []rune) string { + // Split the line as shellwords + args, unprocessed, err := line.Split(string(input), true) 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) { - var buf bytes.Buffer - words = make([]string, 0) - - for len(input) > 0 { - // skip any splitChars at the start - char, read := utf8.DecodeRuneInString(input) - if strings.ContainsRune(splitChars, char) { - input = input[read:] - continue - } else if char == escapeChar { - // Look ahead for escaped newline so we can skip over it - next := input[read:] - if len(next) == 0 { - remainder = string(escapeChar) - err = errUnterminatedEscape - - return words, remainder, err - } - - c2, l2 := utf8.DecodeRuneInString(next) - if c2 == '\n' { - input = next[l2:] - continue - } - } - - var word string - - word, input, err = splitCompWord(input, &buf) - if err != nil { - return words, word + input, err - } - - words = append(words, word) - } - - return words, remainder, nil -} - -// splitWord has been modified to return the remainder of the input (the part that has not been -// added to the buffer) even when an error is returned. -func splitCompWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { - buf.Reset() - -raw: - { - cur := input - for len(cur) > 0 { - char, read := utf8.DecodeRuneInString(cur) - cur = cur[read:] - switch { - case char == singleChar: - buf.WriteString(input[0 : len(input)-len(cur)-read]) - input = cur - goto single - case char == doubleChar: - buf.WriteString(input[0 : len(input)-len(cur)-read]) - input = cur - goto double - case char == escapeChar: - buf.WriteString(input[0 : len(input)-len(cur)-read]) - buf.WriteRune(char) - input = cur - goto escape - case strings.ContainsRune(splitChars, char): - buf.WriteString(input[0 : len(input)-len(cur)-read]) - return buf.String(), cur, nil - } - } - if len(input) > 0 { - buf.WriteString(input) - input = "" - } - goto done - } - -escape: - { - if len(input) == 0 { - input = buf.String() + input - return "", input, errUnterminatedEscape - } - c, l := utf8.DecodeRuneInString(input) - if c != '\n' { - buf.WriteString(input[:l]) - } - input = input[l:] + args = append(args, unprocessed) } - goto raw + done := make([]string, 0) // List of processed words, append to + remain := args // List of words to process, draw from + trimmed := line.TrimSpaces(remain) // Match stuff against trimmed words -single: - { - i := strings.IndexRune(input, singleChar) - if i == -1 { - return "", input, errUnterminatedSingleQuote - } - buf.WriteString(input[0:i]) - input = input[i+1:] - goto raw + // Highlight the root command when found. + cmd, _, _ := c.activeMenu().Find(trimmed) + if cmd != nil { + done, remain = line.HighlightCommand(done, args, c.activeMenu().Command, c.cmdHighlight) } -double: - { - cur := input - for len(cur) > 0 { - c, read := utf8.DecodeRuneInString(cur) - cur = cur[read:] - switch c { - case doubleChar: - buf.WriteString(input[0 : len(input)-len(cur)-read]) - input = cur - goto raw - case escapeChar: - // bash only supports certain escapes in double-quoted strings - char2, l2 := utf8.DecodeRuneInString(cur) - cur = cur[l2:] - if strings.ContainsRune(doubleEscapeChars, char2) { - buf.WriteString(input[0 : len(input)-len(cur)-read-l2]) + // Highlight command flags + done, remain = line.HighlightCommandFlags(done, remain, c.flagHighlight) - if char2 != '\n' { - buf.WriteRune(char2) - } - input = cur - } - } - } + // Done with everything, add remainind, non-processed words + done = append(done, remain...) - return "", input, errUnterminatedDoubleQuote - } + // Join all words. + highlighted := strings.Join(done, "") -done: - return buf.String(), input, nil + return highlighted } - -const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" - -var re = regexp.MustCompile(ansi) - -// strip removes all ANSI escaped color sequences in a string. -func strip(str string) string { - return re.ReplaceAllString(str, "") -} - -var replacer = strings.NewReplacer( - "\n", ` `, - "\t", ` `, - "\\ ", " ", // User-escaped spaces in words. -) diff --git a/console.go b/console.go index bbc0bb5..a8c335f 100644 --- a/console.go +++ b/console.go @@ -6,6 +6,9 @@ import ( "sync" "github.com/reeflective/readline" + + "github.com/reeflective/console/internal/completion" + "github.com/reeflective/console/internal/line" "github.com/reeflective/readline/inputrc" ) @@ -95,14 +98,14 @@ func New(app string) *Console { } // Syntax highlighting, multiline callbacks, etc. - console.cmdHighlight = seqFgGreen - console.flagHighlight = seqBrightWigth - console.shell.AcceptMultiline = console.acceptMultiline - console.shell.SyntaxHighlighter = console.highlightSyntax + console.cmdHighlight = line.GreenFG + console.flagHighlight = line.BrightWhiteFG + console.shell.AcceptMultiline = line.AcceptMultiline + console.shell.SyntaxHighlighter = console.highlightSyntax // Completion console.shell.Completer = console.complete - console.defaultStyleConfig() + completion.DefaultStyleConfig() // Defaults console.EmptyChars = []rune{' ', '\t'} @@ -116,11 +119,36 @@ func (c *Console) Shell() *readline.Shell { return c.shell } + +// +// Settings & Initialisation Functions ------------------------------------------------------------- // +// + // SetPrintLogo - Sets the function that will be called to print the logo. func (c *Console) SetPrintLogo(f func(c *Console)) { c.printLogo = f } +// SetDefaultCommandHighlight allows the user to change the highlight color for +// a command in the default syntax highlighter using an ansi code. +// This action has no effect if a custom syntax highlighter for the shell is set. +// By default, the highlight code is green ("\x1b[32m"). +func (c *Console) SetDefaultCommandHighlight(seq string) { + c.cmdHighlight = seq +} + +// SetDefaultFlagHighlight allows the user to change the highlight color for +// a flag in the default syntax highlighter using an ansi color code. +// This action has no effect if a custom syntax highlighter for the shell is set. +// By default, the highlight code is grey ("\x1b[38;05;244m"). +func (c *Console) SetDefaultFlagHighlight(seq string) { + c.flagHighlight = seq +} + +// +// Menu Management --------------------------------------------------------------------------------- // +// + // NewMenu - Create a new command menu, to which the user // can attach any number of commands (with any nesting), as // well as some specific items like history sources, prompt @@ -185,6 +213,10 @@ func (c *Console) SwitchMenu(menu string) { } } +// +// Message Display Functions ----------------------------------------------------------------------- // +// + // TransientPrintf prints a string message (a log, or more broadly, an asynchronous event) // without bothering the user, displaying the message and "pushing" the prompt below it. // The message is printed regardless of the current menu. @@ -225,6 +257,10 @@ func (c *Console) Printf(msg string, args ...any) (n int, err error) { return c.shell.Printf(msg, args...) } +// +// Other Utility Functions ------------------------------------------------------------------------- // +// + // SystemEditor - This function is a renamed-reexport of the underlying readline.StartEditorWithBuffer // function, which enables you to conveniently edit files/buffers from within the console application. // Naturally, the function will block until the editor is exited, and the updated buffer is returned. @@ -239,12 +275,22 @@ func (c *Console) SystemEditor(buffer []byte, filetype string) ([]byte, error) { } func (c *Console) setupShell() { - cfg := c.shell.Config - // Some options should be set to on because they // are quite neceessary for efficient console use. + cfg := c.shell.Config + + // Input line + cfg.Set("autopairs", true) + cfg.Set("blink-matching-paren", true) + + // Completion + cfg.Set("completion-ignore-case", true) cfg.Set("skip-completed-text", true) cfg.Set("menu-complete-display-prefix", true) + + // General UI + cfg.Set("usage-hint-always", true) + cfg.Set("history-autosuggest", true) } func (c *Console) activeMenu() *Menu { diff --git a/go.mod b/go.mod index 9778bf7..0f201f1 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,9 @@ go 1.23.6 require ( github.com/carapace-sh/carapace v1.7.1 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/reeflective/readline v1.1.2 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc5 + github.com/reeflective/readline v1.1.3 + github.com/rsteube/carapace v0.46.3-0.20231214181515-27e49f3c3b69 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.6 golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac @@ -16,7 +17,8 @@ require ( github.com/carapace-sh/carapace-shlex v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.30.0 // indirect + github.com/rsteube/carapace-shlex v0.1.1 // indirect + golang.org/x/sys v0.32.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7173de6..b44dda9 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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/reeflective/readline v1.1.2 h1:XhnNwVg7gQhrxk2cJ3/taU7KKPXEc9bCzl5oHrSi7aI= -github.com/reeflective/readline v1.1.2/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY= +github.com/reeflective/readline v1.1.3 h1:meGkuEmujZHmalJ9eT3pYkwtkufH5EwYFPTnaph0T0s= +github.com/reeflective/readline v1.1.3/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY= @@ -32,8 +32,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/highlighter.go b/highlighter.go deleted file mode 100644 index 48f53a6..0000000 --- a/highlighter.go +++ /dev/null @@ -1,128 +0,0 @@ -package console - -import ( - "strings" - - "github.com/spf13/cobra" -) - -var ( - seqFgGreen = "\x1b[32m" - seqFgYellow = "\x1b[33m" - seqFgReset = "\x1b[39m" - - seqBrightWigth = "\x1b[38;05;244m" -) - -// Base text effects. -var ( - reset = "\x1b[0m" - bold = "\x1b[1m" - dim = "\x1b[2m" - underscore = "\x1b[4m" - blink = "\x1b[5m" - reverse = "\x1b[7m" - - // Effects reset. - boldReset = "\x1b[22m" // 21 actually causes underline instead - dimReset = "\x1b[22m" - underscoreReset = "\x1b[24m" - blinkReset = "\x1b[25m" - reverseReset = "\x1b[27m" -) - -// SetDefaultCommandHighlight allows the user to change the highlight color for a command in the default syntax -// highlighter using an ansi code. -// This action has no effect if a custom syntax highlighter for the shell is set. -// By default, the highlight code is green ("\x1b[32m"). -func (c *Console) SetDefaultCommandHighlight(seq string) { - c.cmdHighlight = seq -} - -// SetDefaultFlagHighlight allows the user to change the highlight color for a flag in the default syntax -// highlighter using an ansi color code. -// This action has no effect if a custom syntax highlighter for the shell is set. -// By default, the highlight code is grey ("\x1b[38;05;244m"). -func (c *Console) SetDefaultFlagHighlight(seq string) { - c.flagHighlight = seq -} - -// highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console. -func (c *Console) highlightSyntax(input []rune) (line string) { - // Split the line as shellwords - args, unprocessed, err := split(string(input), true) - if err != nil { - args = append(args, unprocessed) - } - - highlighted := make([]string, 0) // List of processed words, append to - remain := args // List of words to process, draw from - trimmed := trimSpacesMatch(remain) // Match stuff against trimmed words - - // Highlight the root command when found. - cmd, _, _ := c.activeMenu().Find(trimmed) - if cmd != nil { - highlighted, remain = c.highlightCommand(highlighted, args, cmd) - } - - // Highlight command flags - highlighted, remain = c.highlightCommandFlags(highlighted, remain, cmd) - - // Done with everything, add remainind, non-processed words - highlighted = append(highlighted, remain...) - - // Join all words. - line = strings.Join(highlighted, "") - - return line -} - -func (c *Console) highlightCommand(done, args []string, _ *cobra.Command) ([]string, []string) { - highlighted := make([]string, 0) - var rest []string - - if len(args) == 0 { - return done, args - } - - // Highlight the root command when found, or any of its aliases. - for _, cmd := range c.activeMenu().Commands() { - // Change 1: Highlight based on first arg in usage rather than the entire usage itself - cmdFound := strings.Split(cmd.Use, " ")[0] == strings.TrimSpace(args[0]) - - for _, alias := range cmd.Aliases { - if alias == strings.TrimSpace(args[0]) { - cmdFound = true - break - } - } - - if cmdFound { - highlighted = append(highlighted, bold+c.cmdHighlight+args[0]+seqFgReset+boldReset) - rest = args[1:] - - return append(done, highlighted...), rest - } - } - - return append(done, highlighted...), args -} - -func (c *Console) highlightCommandFlags(done, args []string, _ *cobra.Command) ([]string, []string) { - highlighted := make([]string, 0) - var rest []string - - if len(args) == 0 { - return done, args - } - - for _, arg := range args { - if strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") { - highlighted = append(highlighted, bold+c.flagHighlight+arg+seqFgReset+boldReset) - } else { - highlighted = append(highlighted, arg) - } - } - - return append(done, highlighted...), rest -} diff --git a/internal/completion/complete.go b/internal/completion/complete.go new file mode 100644 index 0000000..7f97f4e --- /dev/null +++ b/internal/completion/complete.go @@ -0,0 +1,70 @@ +package completion + +import ( + "fmt" + "os" + + "github.com/rsteube/carapace/pkg/style" + "github.com/rsteube/carapace/pkg/xdg" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// DefaultStyleConfig sets some default styles for completion. +func DefaultStyleConfig() { + // If carapace config file is found, just return. + if dir, err := xdg.UserConfigDir(); err == nil { + _, err := os.Stat(fmt.Sprintf("%v/carapace/styles.json", dir)) + if err == nil { + return + } + } + + // Overwrite all default styles for color + for i := 1; i < 13; i++ { + styleStr := fmt.Sprintf("carapace.Highlight%d", i) + style.Set(styleStr, "bright-white") + } + + // Overwrite all default styles for flags + style.Set("carapace.FlagArg", "bright-white") + style.Set("carapace.FlagMultiArg", "bright-white") + style.Set("carapace.FlagNoArg", "bright-white") + style.Set("carapace.FlagOptArg", "bright-white") +} + +// ResetFlagsDefaults resets all flags to their default values. +// +// Slice flags accumulate per execution (and do not reset), +// +// so we must reset them manually. +// +// Example: +// +// Given cmd.Flags().StringSlice("comment", nil, "") +// If you run a command with --comment "a" --comment "b" you will get +// the expected [a, b] slice. +// +// If you run a command again with no --comment flags, you will get +// [a, b] again instead of an empty slice. +// +// If you run the command again with --comment "c" --comment "d" flags, +// you will get [a, b, c, d] instead of just [c, d]. +func ResetFlagsDefaults(target *cobra.Command) { + target.Flags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = false + switch value := flag.Value.(type) { + case pflag.SliceValue: + var res []string + + if len(flag.DefValue) > 0 && flag.DefValue != "[]" { + res = append(res, flag.DefValue) + } + + value.Replace(res) + + default: + flag.Value.Set(flag.DefValue) + } + }) +} diff --git a/internal/completion/line.go b/internal/completion/line.go new file mode 100644 index 0000000..45d3ec4 --- /dev/null +++ b/internal/completion/line.go @@ -0,0 +1,268 @@ +package completion + +import ( + "bytes" + "errors" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/reeflective/console/internal/line" +) + +// 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 +} + +// 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, input string) { + arg = remain + + switch { + case errors.Is(err, line.ErrUnterminatedDoubleQuote): + comp = "\"" + input = comp + arg + case errors.Is(err, line.ErrUnterminatedSingleQuote): + comp = "'" + input = comp + arg + case errors.Is(err, line.ErrUnterminatedEscape): + arg = strings.ReplaceAll(arg, "\\", "") + } + + return arg, comp, input +} + +// 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 +} + +// 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) { + var buf bytes.Buffer + words = make([]string, 0) + + for len(input) > 0 { + // skip any splitChars at the start + char, read := utf8.DecodeRuneInString(input) + if strings.ContainsRune(line.SplitChars, char) { + input = input[read:] + continue + } else if char == line.EscapeChar { + // Look ahead for escaped newline so we can skip over it + next := input[read:] + if len(next) == 0 { + remainder = string(line.EscapeChar) + err = line.ErrUnterminatedEscape + + return words, remainder, err + } + + c2, l2 := utf8.DecodeRuneInString(next) + if c2 == '\n' { + input = next[l2:] + continue + } + } + + var word string + + word, input, err = splitCompWord(input, &buf) + if err != nil { + return words, word + input, err + } + + words = append(words, word) + } + + return words, remainder, nil +} + +// splitWord has been modified to return the remainder of the input (the part that has not been +// added to the buffer) even when an error is returned. +func splitCompWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { + buf.Reset() + +raw: + { + cur := input + for len(cur) > 0 { + char, read := utf8.DecodeRuneInString(cur) + cur = cur[read:] + switch { + case char == line.SingleChar: + buf.WriteString(input[0 : len(input)-len(cur)-read]) + input = cur + goto single + case char == line.DoubleChar: + buf.WriteString(input[0 : len(input)-len(cur)-read]) + input = cur + goto double + case char == line.EscapeChar: + buf.WriteString(input[0 : len(input)-len(cur)-read]) + buf.WriteRune(char) + input = cur + goto escape + case strings.ContainsRune(line.SplitChars, char): + buf.WriteString(input[0 : len(input)-len(cur)-read]) + return buf.String(), cur, nil + } + } + if len(input) > 0 { + buf.WriteString(input) + input = "" + } + goto done + } + +escape: + { + if len(input) == 0 { + input = buf.String() + input + return "", input, line.ErrUnterminatedEscape + } + c, l := utf8.DecodeRuneInString(input) + if c != '\n' { + buf.WriteString(input[:l]) + } + input = input[l:] + } + + goto raw + +single: + { + i := strings.IndexRune(input, line.SingleChar) + if i == -1 { + return "", input, line.ErrUnterminatedSingleQuote + } + buf.WriteString(input[0:i]) + input = input[i+1:] + goto raw + } + +double: + { + cur := input + for len(cur) > 0 { + c, read := utf8.DecodeRuneInString(cur) + cur = cur[read:] + switch c { + case line.DoubleChar: + buf.WriteString(input[0 : len(input)-len(cur)-read]) + input = cur + goto raw + case line.EscapeChar: + // bash only supports certain escapes in double-quoted strings + char2, l2 := utf8.DecodeRuneInString(cur) + cur = cur[l2:] + if strings.ContainsRune(line.DoubleEscapeChars, char2) { + buf.WriteString(input[0 : len(input)-len(cur)-read-l2]) + + if char2 != '\n' { + buf.WriteRune(char2) + } + input = cur + } + } + } + + return "", input, line.ErrUnterminatedDoubleQuote + } + +done: + return buf.String(), input, nil +} + +const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + +var re = regexp.MustCompile(ansi) + +// strip removes all ANSI escaped color sequences in a string. +func strip(str string) string { + return re.ReplaceAllString(str, "") +} + +var replacer = strings.NewReplacer( + "\n", ` `, + "\t", ` `, + "\\ ", " ", // User-escaped spaces in words. +) diff --git a/internal/line/highlight.go b/internal/line/highlight.go new file mode 100644 index 0000000..b84ebd1 --- /dev/null +++ b/internal/line/highlight.go @@ -0,0 +1,81 @@ +package line + +import ( + "slices" + "strings" + + "github.com/spf13/cobra" +) + +var ( + // Base text effects. + Reset = "\x1b[0m" + Bold = "\x1b[1m" + Dim = "\x1b[2m" + Underscore = "\x1b[4m" + Blink = "\x1b[5m" + Reverse = "\x1b[7m" + + // Effects reset. + BoldReset = "\x1b[22m" // 21 actually causes underline instead + DimReset = "\x1b[22m" + UnderscoreReset = "\x1b[24m" + BlinkReset = "\x1b[25m" + ReverseReset = "\x1b[27m" + + // Colors + GreenFG = "\x1b[32m" + YellowFG = "\x1b[33m" + ResetFG = "\x1b[39m" + BrightWhiteFG = "\x1b[38;05;244m" +) + +// HighlightCommand applies highlighting to commands in an input line. +func HighlightCommand(done, args []string, root *cobra.Command, cmdColor string) ([]string, []string) { + highlighted := make([]string, 0) + var rest []string + + if len(args) == 0 { + return done, args + } + + // Highlight the root command when found, or any of its aliases. + for _, cmd := range root.Commands() { + // Change 1: Highlight based on first arg in usage rather than the entire usage itself + cmdFound := strings.Split(cmd.Use, " ")[0] == strings.TrimSpace(args[0]) + + if slices.Contains(cmd.Aliases, strings.TrimSpace(args[0])) { + cmdFound = true + break + } + + if cmdFound { + highlighted = append(highlighted, Bold+cmdColor+args[0]+ResetFG+BoldReset) + rest = args[1:] + + return append(done, highlighted...), rest + } + } + + return append(done, highlighted...), args +} + +// HighlightCommand applies highlighting to command flags in an input line. +func HighlightCommandFlags(done, args []string, flagColor string) ([]string, []string) { + highlighted := make([]string, 0) + var rest []string + + if len(args) == 0 { + return done, args + } + + for _, arg := range args { + if strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") { + highlighted = append(highlighted, Bold+flagColor+arg+ResetFG+BoldReset) + } else { + highlighted = append(highlighted, arg) + } + } + + return append(done, highlighted...), rest +} diff --git a/line.go b/internal/line/line.go similarity index 64% rename from line.go rename to internal/line/line.go index 65a707b..3d0f840 100644 --- a/line.go +++ b/internal/line/line.go @@ -1,4 +1,4 @@ -package console +package line import ( "bytes" @@ -11,22 +11,22 @@ import ( ) var ( - splitChars = " \n\t" - singleChar = '\'' - doubleChar = '"' - escapeChar = '\\' - doubleEscapeChars = "$`\"\n\\" + SplitChars = " \n\t" + SingleChar = '\'' + DoubleChar = '"' + EscapeChar = '\\' + DoubleEscapeChars = "$`\"\n\\" ) var ( - errUnterminatedSingleQuote = errors.New("unterminated single-quoted string") - errUnterminatedDoubleQuote = errors.New("unterminated double-quoted string") - errUnterminatedEscape = errors.New("unterminated backslash-escape") + ErrUnterminatedSingleQuote = errors.New("unterminated single-quoted string") + ErrUnterminatedDoubleQuote = errors.New("unterminated double-quoted string") + ErrUnterminatedEscape = errors.New("unterminated backslash-escape") ) -// parse is in charge of removing all comments from the input line +// Parse is in charge of removing all comments from the input line // before execution, and if successfully parsed, split into words. -func (c *Console) parse(line string) (args []string, err error) { +func Parse(line string) (args []string, err error) { lineReader := strings.NewReader(line) parser := syntax.NewParser(syntax.KeepComments(false)) @@ -49,18 +49,18 @@ func (c *Console) parse(line string) (args []string, err error) { // acceptMultiline determines if the line just accepted is complete (in which case // we should execute it), or incomplete (in which case we must read in multiline). -func (c *Console) acceptMultiline(line []rune) (accept bool) { +func AcceptMultiline(line []rune) (accept bool) { // Errors are either: unterminated quotes, or unterminated escapes. - _, _, err := split(string(line), false) + _, _, err := Split(string(line), false) if err == nil { return true } // Currently, unterminated quotes are obvious to treat: keep reading. switch err { - case errUnterminatedDoubleQuote, errUnterminatedSingleQuote: + case ErrUnterminatedDoubleQuote, ErrUnterminatedSingleQuote: return false - case errUnterminatedEscape: + case ErrUnterminatedEscape: if len(line) > 0 && line[len(line)-1] == '\\' { return false } @@ -71,16 +71,55 @@ func (c *Console) acceptMultiline(line []rune) (accept bool) { return true } -// split has been copied from go-shellquote and slightly modified so as to also +// IsEmpty checks if a given input line is empty. +// It accepts a list of characters that we consider to be irrelevant, +// that is, if the given line only contains these characters, it will +// be considered empty. +func IsEmpty(line string, emptyChars ...rune) bool { + empty := true + + for _, r := range line { + if !strings.ContainsRune(string(emptyChars), r) { + empty = false + break + } + } + + return empty +} + +// UnescapeValue is used When the completer has returned us some completions, +// we sometimes need 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 +} + +// TrimSpaces removes all leading/trailing spaces from words +func TrimSpaces(remain []string) (trimmed []string) { + for _, word := range remain { + trimmed = append(trimmed, strings.TrimSpace(word)) + } + + return +} + +// 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 split(input string, hl bool) (words []string, remainder string, err error) { +func Split(input string, hl bool) (words []string, remainder 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) { + if strings.ContainsRune(SplitChars, c) { // Keep these characters in the result when higlighting the line. if hl { if len(words) == 0 { @@ -93,15 +132,15 @@ func split(input string, hl bool) (words []string, remainder string, err error) input = input[l:] continue - } else if c == escapeChar { + } else if c == EscapeChar { // Look ahead for escaped newline so we can skip over it next := input[l:] if len(next) == 0 { if hl { - remainder = string(escapeChar) + remainder = string(EscapeChar) } - err = errUnterminatedEscape + err = ErrUnterminatedEscape return words, remainder, err } @@ -147,22 +186,22 @@ raw: for len(cur) > 0 { c, l := utf8.DecodeRuneInString(cur) cur = cur[l:] - if c == singleChar { + if c == SingleChar { buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto single - } else if c == doubleChar { + } else if c == DoubleChar { buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto double - } else if c == escapeChar { + } else if c == EscapeChar { buf.WriteString(input[0 : len(input)-len(cur)-l]) if hl { buf.WriteRune(c) } input = cur goto escape - } else if strings.ContainsRune(splitChars, c) { + } else if strings.ContainsRune(SplitChars, c) { buf.WriteString(input[0 : len(input)-len(cur)-l]) if hl { buf.WriteRune(c) @@ -184,7 +223,7 @@ escape: if hl { input = buf.String() + input } - return "", input, errUnterminatedEscape + return "", input, ErrUnterminatedEscape } c, l := utf8.DecodeRuneInString(input) if c == '\n' { @@ -199,25 +238,25 @@ escape: single: { - i := strings.IndexRune(input, singleChar) + i := strings.IndexRune(input, SingleChar) if i == -1 { if hl { - input = buf.String() + seqFgYellow + string(singleChar) + input + input = buf.String() + YellowFG + string(SingleChar) + input } - return "", input, errUnterminatedSingleQuote + return "", input, ErrUnterminatedSingleQuote } // Catch up opening quote if hl { - buf.WriteString(seqFgYellow) - buf.WriteRune(singleChar) + buf.WriteString(YellowFG) + buf.WriteRune(SingleChar) } buf.WriteString(input[0:i]) input = input[i+1:] if hl { - buf.WriteRune(singleChar) - buf.WriteString(seqFgReset) + buf.WriteRune(SingleChar) + buf.WriteString(ResetFG) } goto raw } @@ -228,10 +267,10 @@ double: for len(cur) > 0 { c, l := utf8.DecodeRuneInString(cur) cur = cur[l:] - if c == doubleChar { + if c == DoubleChar { // Catch up opening quote if hl { - buf.WriteString(seqFgYellow) + buf.WriteString(YellowFG) buf.WriteRune(c) } @@ -239,15 +278,15 @@ double: if hl { buf.WriteRune(c) - buf.WriteString(seqFgReset) + buf.WriteString(ResetFG) } input = cur goto raw - } else if c == escapeChar && !hl { + } else if c == EscapeChar && !hl { // bash only supports certain escapes in double-quoted strings c2, l2 := utf8.DecodeRuneInString(cur) cur = cur[l2:] - if strings.ContainsRune(doubleEscapeChars, c2) { + 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 @@ -260,33 +299,13 @@ double: } if hl { - input = buf.String() + seqFgYellow + string(doubleChar) + input + input = buf.String() + YellowFG + string(DoubleChar) + input } - return "", input, errUnterminatedDoubleQuote + return "", input, ErrUnterminatedDoubleQuote } done: return buf.String(), input, nil } -func trimSpacesMatch(remain []string) (trimmed []string) { - for _, word := range remain { - trimmed = append(trimmed, strings.TrimSpace(word)) - } - - return -} - -func (c *Console) lineEmpty(line string) bool { - empty := true - - for _, r := range line { - if !strings.ContainsRune(string(c.EmptyChars), r) { - empty = false - break - } - } - - return empty -} diff --git a/internal/strutil/template.go b/internal/strutil/template.go new file mode 100644 index 0000000..59b96c2 --- /dev/null +++ b/internal/strutil/template.go @@ -0,0 +1,20 @@ +package strutil + +import ( + "io" + "strings" + "text/template" +) + +// Template executes the given template text on data, writing the result to w. +func Template(w io.Writer, text string, data any) error { + t := template.New("top") + t.Funcs(templateFuncs) + template.Must(t.Parse(text)) + + return t.Execute(w, data) +} + +var templateFuncs = template.FuncMap{ + "trim": strings.TrimSpace, +} diff --git a/prompt.go b/internal/ui/prompt.go similarity index 72% rename from prompt.go rename to internal/ui/prompt.go index 01ab9ed..750d38d 100644 --- a/prompt.go +++ b/internal/ui/prompt.go @@ -1,6 +1,7 @@ -package console +package ui import ( + "bytes" "fmt" "strings" @@ -16,27 +17,27 @@ type Prompt struct { Transient func() string // Transient is used if the console shell is configured to be transient. Right func() string // Right is the prompt printed on the right side of the screen. Tooltip func(word string) string // Tooltip is used to hint on the root command, replacing right prompts if not empty. - - console *Console } -func newPrompt(app *Console) *Prompt { - prompt := &Prompt{console: app} +// NewPrompt requires the name of the application and the current menu, +// as well as the current menu output buffer to produce a new, default prompt. +func NewPrompt(appName, menuName string, stdout *bytes.Buffer) *Prompt { + prompt := &Prompt{} prompt.Primary = func() string { - promptStr := app.name + promptStr := appName - menu := app.activeMenu() + // menu := app.activeMenu() - if menu.name == "" { + if menuName == "" { return promptStr + " > " } - promptStr += fmt.Sprintf(" [%s]", menu.name) + promptStr += fmt.Sprintf(" [%s]", menuName) // If the buffered command output is not empty, // add a special status indicator to the prompt. - if strings.TrimSpace(menu.out.String()) != "" { + if strings.TrimSpace(stdout.String()) != "" { promptStr += " $(...)" } @@ -46,8 +47,8 @@ func newPrompt(app *Console) *Prompt { return prompt } -// bind reassigns the prompt printing functions to the shell helpers. -func (p *Prompt) bind(shell *readline.Shell) { +// BindPrompt reassigns the prompt printing functions to the shell helpers. +func BindPrompt(p *Prompt, shell *readline.Shell) { prompt := shell.Prompt // If the user has bound its own primary prompt and the shell diff --git a/menu.go b/menu.go index e6a5bb2..ada9bcd 100644 --- a/menu.go +++ b/menu.go @@ -4,16 +4,21 @@ import ( "bytes" "errors" "fmt" - "io" "strings" "sync" - "text/template" "github.com/spf13/cobra" + "github.com/reeflective/console/internal/strutil" + "github.com/reeflective/console/internal/ui" "github.com/reeflective/readline" ) +// Prompt - A prompt is a set of functions that return the strings to print +// for each prompt type. The console will call these functions to retrieve +// the prompt strings to print. Each menu has its own prompt. +type Prompt = ui.Prompt + // Menu - A menu is a simple way to seggregate commands based on // the environment to which they belong. For instance, when using a menu // specific to some host/user, or domain of activity, commands will vary. @@ -59,7 +64,6 @@ func newMenu(name string, console *Console) *Menu { menu := &Menu{ console: console, name: name, - prompt: newPrompt(console), Command: &cobra.Command{}, out: bytes.NewBuffer(nil), interruptHandlers: make(map[error]func(c *Console)), @@ -68,6 +72,10 @@ func newMenu(name string, console *Console) *Menu { ErrorHandler: defaultErrorHandler, } + // Prompt setup + prompt := (ui.NewPrompt(console.name, name, menu.out)) + menu.prompt = (*Prompt)(prompt) + // Add a default in memory history to each menu // This source is dropped if another source is added // to the menu via `AddHistorySource()`. @@ -214,9 +222,12 @@ func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error { return nil } + errTemplate := m.errorFilteredCommandTemplate(filters) + var bufErr strings.Builder - err := tmpl(&bufErr, m.errorFilteredCommandTemplate(filters), map[string]interface{}{ + + err := strutil.Template(&bufErr, errTemplate, map[string]interface{}{ "menu": m, "cmd": cmd, "filters": filters, @@ -289,9 +300,12 @@ func (m *Menu) resetPreRun() { // Hide commands that are not available m.hideFilteredCommands(m.Command) - // Menu setup - m.resetCmdOutput() // Reset or adjust any buffered command output. - m.prompt.bind(m.console.shell) // Prompt binding + // Reset or adjust any buffered command output. + m.resetCmdOutput() + + // Prompt binding + prompt := (*ui.Prompt)(m.Prompt()) + ui.BindPrompt(prompt, m.console.shell) } // hide commands that are filtered so that they are not @@ -341,16 +355,3 @@ func (m *Menu) errorFilteredCommandTemplate(filters []string) string { return `Command {{.cmd.Name}} is only available for: {{range .filters }} - {{.}} {{end}}` } - -// tmpl executes the given template text on data, writing the result to w. -func tmpl(w io.Writer, text string, data interface{}) error { - t := template.New("top") - t.Funcs(templateFuncs) - template.Must(t.Parse(text)) - - return t.Execute(w, data) -} - -var templateFuncs = template.FuncMap{ - "trim": strings.TrimSpace, -} diff --git a/run.go b/run.go index 9d8eb95..3ce5c19 100644 --- a/run.go +++ b/run.go @@ -10,6 +10,9 @@ import ( "github.com/kballard/go-shellquote" "github.com/spf13/cobra" + + "github.com/reeflective/console/internal/completion" + "github.com/reeflective/console/internal/line" ) // Start - Start the console application (readline loop). Blocking. @@ -45,14 +48,14 @@ func (c *Console) StartContext(ctx context.Context) error { } // Block and read user input. - line, err := c.shell.Readline() + input , err := c.shell.Readline() - c.displayPostRun(line) + c.displayPostRun(input) if err != nil { menu.handleInterrupt(err) - lastLine = line + lastLine = input continue } @@ -63,14 +66,14 @@ func (c *Console) StartContext(ctx context.Context) error { menu = c.activeMenu() // Parse the line with bash-syntax, removing comments. - args, err := c.parse(line) + args, err := line.Parse(input) if err != nil { menu.ErrorHandler(ParseError{newError(err, "Parsing error")}) continue } if len(args) == 0 { - lastLine = line + lastLine = input continue } @@ -91,7 +94,7 @@ func (c *Console) StartContext(ctx context.Context) error { menu.ErrorHandler(ExecutionError{newError(err, "")}) } - lastLine = line + lastLine = input } } @@ -157,7 +160,7 @@ func (c *Console) execute(ctx context.Context, menu *Menu, args []string, async } // Reset all flags to their default values. - resetFlagsDefaults(target) + completion.ResetFlagsDefaults(target) // Console-wide pre-run hooks, cannot. if err := c.runAllE(c.PreCmdRunHooks); err != nil { @@ -250,10 +253,10 @@ func (c *Console) runLineHooks(args []string) ([]string, error) { return processed, nil } -func (c *Console) displayPreRun(line string) { +func (c *Console) displayPreRun(input string) { if c.NewlineBefore { if !c.NewlineWhenEmpty { - if !c.lineEmpty(line) { + if !line.IsEmpty(input, c.EmptyChars...) { fmt.Println() } } else { @@ -265,7 +268,7 @@ func (c *Console) displayPreRun(line string) { func (c *Console) displayPostRun(lastLine string) { if c.NewlineAfter { if !c.NewlineWhenEmpty { - if !c.lineEmpty(lastLine) { + if !line.IsEmpty(lastLine, c.EmptyChars...) { fmt.Println() } } else {