diff --git a/README.md b/README.md index 176adbb..436e634 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ -# tui \ No newline at end of file +# tui + +## Maintenance + +- [readline / console upstream sync](docs/readline-console-upstream-sync.md) diff --git a/console/.github/workflows/codacy.yml b/console/.github/workflows/codacy.yml index 3bc2f53..33d4ba8 100644 --- a/console/.github/workflows/codacy.yml +++ b/console/.github/workflows/codacy.yml @@ -36,7 +36,7 @@ jobs: steps: # Checkout the repository to the GitHub Actions runner - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis - name: Run Codacy Analysis CLI @@ -56,6 +56,6 @@ jobs: # Upload the SARIF file generated in the previous step - name: Upload SARIF results file - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v3 with: sarif_file: results.sarif diff --git a/console/.github/workflows/codeql.yml b/console/.github/workflows/codeql.yml index 11f9b30..6d77688 100644 --- a/console/.github/workflows/codeql.yml +++ b/console/.github/workflows/codeql.yml @@ -40,11 +40,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -71,6 +71,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/console/.github/workflows/dependency-review.yml b/console/.github/workflows/dependency-review.yml index fe461b4..222041e 100644 --- a/console/.github/workflows/dependency-review.yml +++ b/console/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: 'Dependency Review' - uses: actions/dependency-review-action@v2 + uses: actions/dependency-review-action@v5 diff --git a/console/.github/workflows/go.yml b/console/.github/workflows/go.yml index 1521716..522bbce 100644 --- a/console/.github/workflows/go.yml +++ b/console/.github/workflows/go.yml @@ -18,12 +18,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: - go-version: '1.21' + go-version-file: go.mod - name: Build run: go build -v ./... @@ -31,18 +31,21 @@ jobs: - name: Run coverage run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false windows: runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: - go-version: '1.21' + go-version-file: go.mod - name: Build run: go build -v ./... diff --git a/console/.github/workflows/label.yml b/console/.github/workflows/label.yml index a8a1bd7..c0445f6 100644 --- a/console/.github/workflows/label.yml +++ b/console/.github/workflows/label.yml @@ -17,6 +17,6 @@ jobs: pull-requests: write steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v6 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/console/.gitignore b/console/.gitignore index ae85226..e1603b5 100644 --- a/console/.gitignore +++ b/console/.gitignore @@ -1 +1,3 @@ console.wiki +.gemini +.claude diff --git a/console/README.md b/console/README.md index 0e69b3d..e06f233 100644 --- a/console/README.md +++ b/console/README.md @@ -9,23 +9,23 @@

- - + Github Actions (workflows) - + Go module version - + GoDoc reference - - + Go Report Card @@ -40,7 +40,7 @@

-Console is an all-in-one console application library built on top of a [readline](https://github.com/reeflective/readline) shell and using [Cobra](https://github.com/spf13/cobra) commands. +Console is an all-in-one console application library built on top of a [readline](https://github.com/chainreactors/tui/readline) shell and using [Cobra](https://github.com/spf13/cobra) commands. It aims to provide users with a modern interface at at minimal cost while allowing them to focus on developing their commands and application core: the console will then transparently interface with these commands, and provide the various features below almost for free. @@ -55,7 +55,7 @@ the various features below almost for free. - Bind handlers to special interrupt errors (eg. `CtrlC`/`CtrlD`), per menu. ### Shell interface -- Shell is powered by a [readline](https://github.com/reeflective/readline) instance, with full `inputrc` support and extended functionality. +- Shell is powered by a [readline](https://github.com/chainreactors/tui/readline) instance, with full `inputrc` support and extended functionality. - All features of readline are supported in the console. It also allows the console to give: - Configurable bind keymaps, commands and options, sane defaults, and per-application configuration. - Out-of-the-box, advanced completions for commands, flags, positional and flag arguments. @@ -71,23 +71,23 @@ the various features below almost for free. ## Documentation -You can install and use the [example application console](https://github.com/reeflective/console/tree/main/example). This example application +You can install and use the [example application console](https://github.com/chainreactors/tui/console/tree/main/example). This example application will give you a taste of the behavior and supported features. The following documentation -is also available in the [wiki](https://github.com/reeflective/console/wiki): +is also available in the [wiki](https://github.com/chainreactors/tui/console/wiki): -* [Getting started](https://github.com/reeflective/console/wiki/Getting-Started) -* [Menus](https://github.com/reeflective/console/wiki/Menus) -* [Prompts](https://github.com/reeflective/console/wiki/Prompts) -* [Binding commands](https://github.com/reeflective/console/wiki/Binding-Commands) -* [Interrupt handlers](https://github.com/reeflective/console/wiki/Interrupt-Handlers) -* [History Sources](https://github.com/reeflective/console/wiki/History-Sources) -* [Logging](https://github.com/reeflective/console/wiki/Logging) -* [Readline shell](https://github.com/reeflective/readline/wiki) -* [Other utilities](https://github.com/reeflective/console/wiki/Other-Utililites) +* [Getting started](https://github.com/chainreactors/tui/console/wiki/Getting-Started) +* [Menus](https://github.com/chainreactors/tui/console/wiki/Menus) +* [Prompts](https://github.com/chainreactors/tui/console/wiki/Prompts) +* [Binding commands](https://github.com/chainreactors/tui/console/wiki/Binding-Commands) +* [Interrupt handlers](https://github.com/chainreactors/tui/console/wiki/Interrupt-Handlers) +* [History Sources](https://github.com/chainreactors/tui/console/wiki/History-Sources) +* [Logging](https://github.com/chainreactors/tui/console/wiki/Logging) +* [Readline shell](https://github.com/chainreactors/tui/readline/wiki) +* [Other utilities](https://github.com/chainreactors/tui/console/wiki/Other-Utililites) ## Showcase -![console](https://github.com/reeflective/console/blob/assets/console.gif) +![console](https://github.com/chainreactors/tui/console/blob/assets/console.gif) ## Status @@ -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/console/command.go b/console/command.go index a33a7c5..49d8276 100644 --- a/console/command.go +++ b/console/command.go @@ -21,8 +21,8 @@ type Commands func() *cobra.Command // SetCommands requires a function returning a tree of cobra commands to be used. func (m *Menu) SetCommands(cmds Commands) { - m.mutex.RLock() - defer m.mutex.RUnlock() + m.mutex.Lock() + defer m.mutex.Unlock() m.cmds = cmds } @@ -32,6 +32,9 @@ func (m *Menu) SetCommands(cmds Commands) { // If "windows" is used as the argument here, all windows commands for the current // menu are subsequently hidden, until ShowCommands("windows") is called. func (c *Console) HideCommands(filters ...string) { + c.mutex.Lock() + defer c.mutex.Unlock() + next: for _, filt := range filters { for _, filter := range c.filters { @@ -51,8 +54,8 @@ next: // Use this function if you have previously called HideCommands("filter") and want // these commands to be available back under their respective menu. func (c *Console) ShowCommands(filters ...string) { - c.mutex.RLock() - defer c.mutex.RUnlock() + c.mutex.Lock() + defer c.mutex.Unlock() updated := make([]string, 0) @@ -76,23 +79,11 @@ next: } // 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) { + if target == nil { + return + } + target.Flags().VisitAll(func(flag *pflag.Flag) { flag.Changed = false switch value := flag.Value.(type) { @@ -104,9 +95,8 @@ func resetFlagsDefaults(target *cobra.Command) { } value.Replace(res) - default: - flag.Value.Set(flag.DefValue) + _ = flag.Value.Set(flag.DefValue) } }) } diff --git a/console/completer.go b/console/completer.go index 975bb7d..3be2d9b 100644 --- a/console/completer.go +++ b/console/completer.go @@ -1,30 +1,23 @@ 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/chainreactors/tui/readline" "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/chainreactors/tui/readline" + "github.com/chainreactors/tui/console/internal/completion" + "github.com/chainreactors/tui/console/internal/line" ) -func (c *Console) complete(line []rune, pos int) (comps readline.Completions) { +func (c *Console) complete(input []rune, pos int) (comps readline.Completions) { menu := c.activeMenu() - // NOTE: Cobra command parsing state is mutable and can leak across - // completion invocations (e.g., ArgsLenAtDash). If carapace panics, - // we recover to avoid taking down the whole interactive shell. defer func() { if r := recover(); r != nil { comps = readline.CompleteMessage(fmt.Sprintf("completion error: %v", r)) @@ -35,27 +28,12 @@ func (c *Console) complete(line []rune, pos int) (comps readline.Completions) { // Ensure the carapace library is called so that the function // completer.Complete() variable is correctly initialized before use. carapace.Gen(menu.Command) - - // Hide internal _carapace subcommands before completion so they - // never appear as candidates. hideCarapaceCommands(menu.Command) // Split the line as shell words, only using // what the right buffer (up to the cursor) - args, prefixComp, prefixLine := splitArgs(line, pos) - - // Cobra/pflag parsing state is mutable: both real command executions and - // carapace completion parsing will mark flags as Changed and record dash state. - // - // If we keep reusing the same cobra command tree, this state can leak into - // subsequent completion calls and make the UI appear "stale" (e.g., some flags - // disappear because they are considered already set). Reset the state for the - // current command path before completing. + args, prefixComp, prefixLine := completion.SplitArgs(input, pos) resetFlagParsingState(menu.Command, args) - - // pflag doesn't reset ArgsLenAtDash between parses, so a prior execution with a bare - // `--` can permanently break flag completion (carapace will think it's after `--`). - // Reset the flag parsing dash state for the current command path before completing. resetArgsLenAtDash(menu.Command, args) // Prepare arguments for the carapace completer @@ -64,30 +42,21 @@ func (c *Console) complete(line []rune, pos int) (comps readline.Completions) { // Call the completer with our current command context. completions, err := completer.Complete(menu.Command, args...) - if err != nil { - // If carapace/cobra state got into a bad state, reset the menu commands so - // subsequent completion attempts do not keep failing. - menu.resetPreRun() - } // The completions are never nil: fill out our own object // with everything it contains, regardless of errors. raw := make([]readline.Completion, 0, len(completions.Values)) - ansiEscape := regexp.MustCompile(`\x1b\[[0-9;]*m`) - - for _, val := range completions.Values.Decolor() { - val.Value = ansiEscape.ReplaceAllString(val.Value, "") - // Filter out carapace internal commands from completion candidates. + for _, val := range completions.Values { if strings.TrimSpace(val.Value) == "_carapace" { continue } comp := 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, } @@ -95,6 +64,13 @@ func (c *Console) complete(line []rune, pos int) (comps readline.Completions) { comp.Value = val.Value + " " } + // Remove short/long flags grouping + // join to single tag group for classic zsh side-by-side view + switch val.Tag { + case "shorthand flags", "longhand flags": + comp.Tag = "flags" + } + raw = append(raw, comp) } @@ -112,6 +88,7 @@ func (c *Console) complete(line []rune, pos int) (comps readline.Completions) { // If any errors arose from the completion call itself. if err != nil { comps = readline.CompleteMessage("failed to load config: " + err.Error()) + menu.resetPreRun() } // Completion status/errors @@ -129,20 +106,16 @@ func (c *Console) complete(line []rune, pos int) (comps readline.Completions) { // for in our completions, add it to all of them. comps = comps.Prefix(prefixComp) comps.PREFIX = prefixLine + c.setInlineSuggestion(input, pos, comps) - // Set inline suggestion (fish-style gray text) - c.setInlineSuggestion(line, pos, comps) - - // Reset state for the next call. - // - // Only clear carapace storage when commands are regenerated per completion - // invocation (menu.SetCommands). If the command tree is reused, clearing the - // global carapace storage would drop all registered completions. + // Finally, reset our command tree for the next call. Only the commands need + // regenerating here: the prompt is already bound and no command output was + // produced, so the full resetPreRun would just be wasted work per keystroke. + // (resetCommands already re-hides filtered commands.) if menu.cmds != nil { completer.ClearStorage() } - menu.resetPreRun() - menu.hideFilteredCommands(menu.Command) + menu.resetCommands() return comps } @@ -153,10 +126,7 @@ func resetFlagParsingState(root *cobra.Command, args []string) { } target := findCompletionTarget(root, args) - - // Ensure persistent flags are merged so we reset the full flag set (local + parents). _ = target.LocalFlags() - resetFlagsDefaults(target) } @@ -166,8 +136,6 @@ func resetArgsLenAtDash(root *cobra.Command, args []string) { } target := findCompletionTarget(root, args) - - // Reset along the parent chain because persistent flags can live on parents. for cmd := target; cmd != nil; cmd = cmd.Parent() { resetFlagSetArgsLenAtDash(cmd.Flags(), cmd.DisplayName()) resetFlagSetArgsLenAtDash(cmd.PersistentFlags(), cmd.DisplayName()) @@ -178,13 +146,13 @@ func resetFlagSetArgsLenAtDash(fs *pflag.FlagSet, name string) { if fs == nil { return } + fs.Init(name, pflag.ContinueOnError) } func findCompletionTarget(root *cobra.Command, args []string) *cobra.Command { cmd := root for _, arg := range args { - // Stop at flags (including the `--` terminator): command path is complete. if arg == "--" || strings.HasPrefix(arg, "-") { break } @@ -195,6 +163,7 @@ func findCompletionTarget(root *cobra.Command, args []string) *cobra.Command { } cmd = next } + return cmd } @@ -202,14 +171,19 @@ func findSubcommand(cmd *cobra.Command, name string) *cobra.Command { if cmd == nil { return nil } + for _, sub := range cmd.Commands() { if sub.Name() == name || sub.HasAlias(name) { return sub } } + return nil } +// 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{} @@ -219,6 +193,7 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl } justified = append(justified, comp.Tag) + comp.Style = "" // Remove command coloring return comp }) @@ -231,16 +206,17 @@ func (c *Console) justifyCommandComps(comps readline.Completions) readline.Compl } // setInlineSuggestion sets a fish-style inline suggestion based on completion candidates. -func (c *Console) setInlineSuggestion(line []rune, pos int, completions readline.Completions) { - // Only show suggestion when cursor is at end of line - if pos != len(line) { - c.shell.ClearInlineSuggestion() +func (c *Console) setInlineSuggestion(input []rune, pos int, completions readline.Completions) { + if c.shell == nil { return } - currentLine := string(line) + if pos != len(input) { + c.shell.ClearInlineSuggestion() + return + } - // No completions, clear suggestion + currentLine := string(input) var values []string completions.EachValue(func(comp readline.Completion) readline.Completion { values = append(values, comp.Value) @@ -251,10 +227,8 @@ func (c *Console) setInlineSuggestion(line []rune, pos int, completions readline return } - // Get the current word prefix for matching prefix := completions.PREFIX if prefix == "" || !strings.HasSuffix(currentLine, prefix) { - // Calculate prefix from the last word lastSpace := strings.LastIndexAny(currentLine, " \t") if lastSpace >= 0 { prefix = currentLine[lastSpace+1:] @@ -264,7 +238,7 @@ func (c *Console) setInlineSuggestion(line []rune, pos int, completions readline } ignoreCase := false - if c.shell != nil && c.shell.Config != nil { + if c.shell.Config != nil { ignoreCase = c.shell.Config.GetBool("completion-ignore-case") } @@ -273,7 +247,6 @@ func (c *Console) setInlineSuggestion(line []rune, pos int, completions readline matchPrefix = strings.ToLower(prefix) } - // Collect matching values var matchingValues []string for _, value := range values { value = strings.TrimRightFunc(value, unicode.IsSpace) @@ -290,342 +263,108 @@ func (c *Console) setInlineSuggestion(line []rune, pos int, completions readline matchingValues = append(matchingValues, value) } } - if len(matchingValues) == 0 { c.shell.ClearInlineSuggestion() return } - var suggestion string - if len(matchingValues) == 1 { - // Single candidate: use it directly - suggestion = matchingValues[0] - } else { - // Multiple candidates: compute common prefix + suggestion := matchingValues[0] + if len(matchingValues) > 1 { suggestion = longestCommonPrefix(matchingValues, ignoreCase) } - // Only show if suggestion is longer than current prefix if len(suggestion) <= len(prefix) { c.shell.ClearInlineSuggestion() return } - // Build full line suggestion using the current line prefix. - fullSuggestion := currentLine + suggestion[len(prefix):] - c.shell.SetInlineSuggestion(fullSuggestion) + c.shell.SetInlineSuggestion(currentLine + suggestion[len(prefix):]) } -// longestCommonPrefix returns the longest common prefix of a slice of strings. func longestCommonPrefix(strs []string, ignoreCase bool) string { if len(strs) == 0 { return "" } + prefix := []rune(strs[0]) comparePrefix := []rune(strs[0]) if ignoreCase { comparePrefix = []rune(strings.ToLower(strs[0])) } + for _, s := range strs[1:] { runes := []rune(s) compareRunes := runes if ignoreCase { compareRunes = []rune(strings.ToLower(s)) } + i := 0 for i < len(comparePrefix) && i < len(compareRunes) && comparePrefix[i] == compareRunes[i] { i++ } + prefix = prefix[:i] comparePrefix = comparePrefix[:i] if len(comparePrefix) == 0 { break } } - return string(prefix) -} - -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") + return string(prefix) } -// 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 +// highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console. +func (c *Console) highlightSyntax(input []rune) string { + // Serve a memoized result when the input has not changed since the last + // render. The cache is cleared whenever the command tree is regenerated, + // so a stale tree can never produce a stale highlight. + key := string(input) + if cached := c.hlCache.Load(); cached != nil && cached.input == key { + return cached.output } - // 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) + highlighted := c.computeHighlight(input) + c.hlCache.Store(&highlightCache{input: key, output: highlighted}) - return sanitizeArgs(args), prefixComp, prefixLine + return highlighted } -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. +func (c *Console) computeHighlight(input []rune) string { + // Split the line as shellwords + args, unprocessed, err := line.Split(string(input), true) if err != nil { - return false, args, remain + args = append(args, unprocessed) } - lastChar := line[len(line)-1] + 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 - // 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 + // 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) } - // 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 -} + // Highlight command flags + done, remain = line.HighlightCommandFlags(done, remain, c.flagHighlight) -func adjustQuotedPrefix(remain string, err error) (arg, comp, line string) { - arg = remain + // Done with everything, add remainind, non-processed words + done = append(done, 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) - } + // Join all words. + highlighted := strings.Join(done, "") - return sanitized + return highlighted } -// 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:] - } - - goto raw - -single: - { - i := strings.IndexRune(input, singleChar) - if i == -1 { - return "", input, 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 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]) - - if char2 != '\n' { - buf.WriteRune(char2) - } - input = cur - } - } - } - - return "", input, errUnterminatedDoubleQuote +// hideCarapaceCommands recursively hides all _carapace internal subcommands. +func hideCarapaceCommands(root *cobra.Command) { + if root == nil { + return } -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. -) - -// hideCarapaceCommands recursively hides all _carapace internal -// subcommands so they never appear in completion candidates or help. -func hideCarapaceCommands(root *cobra.Command) { for _, cmd := range root.Commands() { if cmd.Name() == "_carapace" { cmd.Hidden = true diff --git a/console/concurrency_test.go b/console/concurrency_test.go new file mode 100644 index 0000000..6920110 --- /dev/null +++ b/console/concurrency_test.go @@ -0,0 +1,68 @@ +package console + +import ( + "errors" + "sync" + "testing" + + "github.com/spf13/cobra" +) + +// TestConcurrentStateAccess stresses the console's shared state (filters, the +// menus map, and per-menu interrupt handlers) from many goroutines at once. +// +// It is meant to be run with the race detector (`go test -race`). Before the +// locking fixes, these paths mutated maps/slices under a read lock (or no lock +// at all), which the detector flags and which can panic on concurrent map +// writes in production. +func TestConcurrentStateAccess(t *testing.T) { + c := New("test") + + // Give the active menu a small command tree so that ActiveFiltersFor has + // something to recurse over while filters are being mutated concurrently. + menu := c.ActiveMenu() + menu.SetCommands(func() *cobra.Command { + root := &cobra.Command{Use: "root"} + child := &cobra.Command{ + Use: "child", + Annotations: map[string]string{CommandFilterKey: "filterA,filterB"}, + Run: func(*cobra.Command, []string) {}, + } + root.AddCommand(child) + return root + }) + menu.resetPreRun() + + errInt := errors.New("interrupt") + + const workers = 64 + + var wg sync.WaitGroup + wg.Add(workers) + + for i := 0; i < workers; i++ { + go func(i int) { + defer wg.Done() + + // Filters: concurrent writers (Hide/Show) and readers (ActiveFiltersFor). + c.HideCommands("filterA", "filterB") + c.ShowCommands("filterA") + + m := c.ActiveMenu() + for _, cmd := range m.Command.Commands() { + _ = m.ActiveFiltersFor(cmd) + } + + // Menus map: concurrent creation and lookup. + _ = c.NewMenu("menu") + _ = c.Menu("menu") + _ = c.ActiveMenu() + + // Interrupt handlers map: concurrent writers. + m.AddInterrupt(errInt, func(*Console) {}) + m.DelInterrupt(errInt) + }(i) + } + + wg.Wait() +} diff --git a/console/console.go b/console/console.go index 1125d51..fa7601a 100644 --- a/console/console.go +++ b/console/console.go @@ -2,27 +2,48 @@ package console import ( "fmt" + "os" + "strings" "sync" + "sync/atomic" "github.com/chainreactors/tui/readline" rlterm "github.com/chainreactors/tui/readline/terminal" + + "github.com/chainreactors/tui/console/internal/completion" + "github.com/chainreactors/tui/console/internal/line" + "github.com/chainreactors/tui/readline/inputrc" ) +// highlightCache holds a memoized syntax-highlighting result for one input. +type highlightCache struct { + input string + output string +} + // Console is an integrated console application instance. type Console struct { // Application - name string // Used in the prompt, and for readline `.inputrc` application-specific settings. - shell *readline.Shell // Provides readline functionality (inputs, completions, hints, history) - terminal *rlterm.Terminal + name string // Used in the prompt, and for readline `.inputrc` application-specific settings. + shell *readline.Shell // Provides readline functionality (inputs, completions, hints, history) + terminal *rlterm.Terminal // Transport backing this console. printLogo func(c *Console) // Simple logo printer. cmdHighlight string // Ansi code for highlighting of command in default highlighter. Green by default. flagHighlight string // Ansi code for highlighting of flag in default highlighter. Grey by default. menus map[string]*Menu // Different command trees, prompt engines, etc. + current *Menu // Cached pointer to the active menu (guarded by mutex). filters []string // Hide commands based on their attributes and current context. - isExecuting bool // Used by log functions, which need to adapt behavior (print the prompt, etc.) + isExecuting atomic.Bool // Used by log functions, which need to adapt behavior (print the prompt, etc.) printed bool // Used to adjust asynchronous messages too. mutex *sync.RWMutex // Concurrency management. + // hlCache memoizes the last syntax-highlighting result. The highlighter is + // called on every render (even when only the cursor moved), so caching the + // output for an unchanged input avoids re-splitting and re-walking the + // command tree. It is invalidated whenever the command tree is regenerated + // (see Menu.regenerate), so the input alone is a sufficient key. + hlCache atomic.Pointer[highlightCache] + pasteMu sync.Mutex pasteConfig PasteReferenceConfig pasteCounter int @@ -31,6 +52,8 @@ type Console struct { // Execution // Leave an empty line before executing the command. + // This is the console-wide default; a menu may override it with + // Menu.SetNewlineBefore. NewlineBefore bool // Leave an empty line after executing the command. @@ -38,19 +61,31 @@ type Console struct { // with TransientPrintf(), Printf() calls, you should leave this to false, // and add a leading newline to your prompt instead: the readline shell will // know how to handle it in all situations. + // This is the console-wide default; a menu may override it with + // Menu.SetNewlineAfter. NewlineAfter bool // Leave empty lines with NewlineBefore and NewlineAfter, even if the provided input was empty. // Empty characters are defined as any number of spaces and tabs. The 'empty' character set // can be changed by modifying Console.EmptyChars // This field is false by default. + // This is the console-wide default; a menu may override it with + // Menu.SetNewlineWhenEmpty. NewlineWhenEmpty bool // Characters that are used to determine whether an input line was empty. If a line is not entirely // made up by any of these characters, then it is not considered empty. The default characters // are ' ' and '\t'. + // This is the console-wide default; a menu may override it with + // Menu.SetEmptyChars. EmptyChars []rune + // Signals is the set of OS signals the console traps while a command is + // running. When one is received, the running command's context is + // cancelled (see StartContext for the cancellation model). If empty, the + // console defaults to SIGINT, SIGTERM and SIGQUIT. + Signals []os.Signal + // PreReadlineHooks - All the functions in this list will be executed, // in their respective orders, before the console starts reading // any user input (ie, before redrawing the prompt). @@ -86,11 +121,13 @@ func New(app string) *Console { func NewWithTerminal(app string, t *rlterm.Terminal) *Console { if t == nil { t = rlterm.Local() + t.Out = stdoutWriter{} + t.Err = stderrWriter{} } + console := &Console{ - name: app, - //shell: readline.NewShell(inputrc.WithApp(strings.ToLower(app))), - shell: readline.NewShellWithTerminal(t), + name: app, + shell: readline.NewShellWithTerminal(t, inputrc.WithApp(strings.ToLower(app))), terminal: t, menus: make(map[string]*Menu), mutex: &sync.RWMutex{}, @@ -103,6 +140,7 @@ func NewWithTerminal(app string, t *rlterm.Terminal) *Console { // Each menu is created with a default prompt engine. defaultMenu := console.NewMenu("") defaultMenu.active = true + console.current = defaultMenu // Set the history for this menu for _, name := range defaultMenu.historyNames { @@ -110,17 +148,18 @@ func NewWithTerminal(app string, t *rlterm.Terminal) *Console { } // Syntax highlighting, multiline callbacks, etc. - console.cmdHighlight = seqFgGreen - console.flagHighlight = seqBrightWigth - console.shell.AcceptMultiline = console.acceptMultiline + 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'} + console.Signals = append([]os.Signal(nil), defaultTrapSignals...) return console } @@ -131,18 +170,42 @@ 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 // configurations, sets of expanded variables, and others. func (c *Console) NewMenu(name string) *Menu { - c.mutex.RLock() - defer c.mutex.RUnlock() + c.mutex.Lock() + defer c.mutex.Unlock() menu := newMenu(name, c) c.menus[name] = menu @@ -151,16 +214,13 @@ func (c *Console) NewMenu(name string) *Menu { // ActiveMenu - Return the currently used console menu. func (c *Console) ActiveMenu() *Menu { - c.mutex.Lock() - defer c.mutex.Unlock() - return c.activeMenu() } // Menu returns one of the console menus by name, or nil if no menu is found. func (c *Console) Menu(name string) *Menu { - c.mutex.Lock() - defer c.mutex.Unlock() + c.mutex.RLock() + defer c.mutex.RUnlock() return c.menus[name] } @@ -171,35 +231,45 @@ func (c *Console) Menu(name string) *Menu { // are bound to this menu name, the current menu is kept. func (c *Console) SwitchMenu(menu string) { c.mutex.Lock() + target, found := c.menus[menu] - c.mutex.Unlock() + current := c.current - if found && target != nil { - // Only switch if the target menu was found. - current := c.activeMenu() - if current != nil && target == current { - return - } + // Only switch if the target menu was found and is not already current. + if !found || target == nil || target == current { + c.mutex.Unlock() + return + } - if current != nil { - current.active = false - } + if current != nil { + current.active = false + } - target.active = true + target.active = true + c.current = target - // Remove the currently bound history sources - // (old menu) and bind the ones peculiar to this one. - c.shell.History.Delete() + c.mutex.Unlock() - for _, name := range target.historyNames { - c.shell.History.Add(name, target.histories[name]) - } + // The following touches the shell and regenerates the menu commands, + // which itself reacquires c.mutex (history/filters): it must run with + // the lock released to avoid a self-deadlock. - // Regenerate the commands, outputs and everything related. - target.resetPreRun() + // Remove the currently bound history sources + // (old menu) and bind the ones peculiar to this one. + c.shell.History.Delete() + + for _, name := range target.historyNames { + c.shell.History.Add(name, target.histories[name]) } + + // Regenerate the commands, outputs and everything related. + target.resetPreRun() } +// +// 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. @@ -207,18 +277,20 @@ func (c *Console) SwitchMenu(menu string) { // If this function is called while a command is running, the console will simply print the log // below the line, and will not print the prompt. In any other case this function works normally. func (c *Console) TransientPrintf(msg string, args ...any) (n int, err error) { - if c.isExecuting { + if c.isExecuting.Load() { return fmt.Fprintf(c.terminal.Out, msg, args...) } + newlineAfter := c.activeMenu().newlineAfter() + // If the last message we printed asynchronously // immediately precedes this new message, move up // another row, so we don't waste too much space. - if c.printed && c.NewlineAfter { + if c.printed && newlineAfter { fmt.Fprint(c.terminal.Out, "\x1b[1A") } - if c.NewlineAfter { + if newlineAfter { msg += "\n" } @@ -233,13 +305,17 @@ func (c *Console) TransientPrintf(msg string, args ...any) (n int, err error) { // If this function is called while a command is running, the console will simply print the log // below the line, and will not print the prompt. In any other case this function works normally. func (c *Console) Printf(msg string, args ...any) (n int, err error) { - if c.isExecuting { + if c.isExecuting.Load() { return fmt.Fprintf(c.terminal.Out, msg, args...) } 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. @@ -254,22 +330,45 @@ 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("autocomplete", true) cfg.Set("skip-completed-text", true) cfg.Set("menu-complete-display-prefix", true) - cfg.Set("autocomplete", true) // Enable as-you-type completion for inline suggestions - cfg.Set("usage-hint-always", true) // Always show command usage hints below input + + // General UI + cfg.Set("usage-hint-always", true) + cfg.Set("history-autosuggest", true) } func (c *Console) activeMenu() *Menu { - for _, menu := range c.menus { - if menu.active { - return menu - } + c.mutex.RLock() + defer c.mutex.RUnlock() + + if c.current != nil { + return c.current } // Else return the default menu. return c.menus[""] } + +type stdoutWriter struct{} + +func (stdoutWriter) Write(p []byte) (int, error) { + return os.Stdout.Write(p) +} + +type stderrWriter struct{} + +func (stderrWriter) Write(p []byte) (int, error) { + return os.Stderr.Write(p) +} diff --git a/console/example/.example-history b/console/example/.example-history new file mode 100644 index 0000000..e8bb50b --- /dev/null +++ b/console/example/.example-history @@ -0,0 +1,105 @@ +{"datetime":"2023-01-11T03:55:31.19768021+01:00","block":"multiple-ambiguous \"10.203.23.45\nanother test\""} +{"datetime":"2023-01-11T04:19:34.996246836+01:00","block":"list-first 10.203.23.45 \"127.0.0.1 \nanother test\""} +{"datetime":"2023-01-11T05:22:18.391148425+01:00","block":"multiple-ambiguous \"10.203.23.45 \nanother line\""} +{"datetime":"2023-01-11T17:36:12.608735363+01:00","block":"multiple-ambiguous \"192.168.1.1 \ntesting that it works\nfine like this\" and shit like that"} +{"datetime":"2023-01-11T23:48:06.79239968+01:00","block":"git command \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa another word here aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nanother testing here aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\""} +{"datetime":"2023-01-12T00:09:40.826273519+01:00","block":"git \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa another word here aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nanother testing here aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \nanother try to check more than 3 lines\" here"} +{"datetime":"2023-01-13T17:42:05.251704029+01:00","block":"git testing \"this string\nwith another string here that is longer\nand another one short\""} +{"datetime":"2023-01-14T03:22:30.224150635+01:00","block":"git testing \"this string\nanother test with another string here that is longer another one\nand another one short\""} +{"datetime":"2023-01-15T03:06:12.516821066+01:00","block":"git \"another\n\ntesting\n\nthis\""} +{"datetime":"2023-01-15T07:30:08.742273898+01:00","block":"multiple-ambiguous \"10.203.23.45\nanother test\""} +{"datetime":"2023-01-15T07:31:00.929042673+01:00","block":"multiple-ambiguous \"10.203.23.45\nanother test\""} +{"datetime":"2023-01-15T09:46:52.147658459+01:00","block":"git testing \"this stringand another one short\" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"datetime":"2023-01-15T09:56:00.281185044+01:00","block":"git this sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss"} +{"datetime":"2023-01-15T10:00:55.285177188+01:00","block":"git testing thataaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"datetime":"2023-01-15T10:04:31.337350601+01:00","block":"git anaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"datetime":"2023-01-15T11:27:55.062589134+01:00","block":"git \"another\nis another sentence\nnot yet finished\""} +{"datetime":"2023-01-16T05:59:19.888868992+01:00","block":"git \"test\nanother testing which\nworks quite fine here\""} +{"datetime":"2023-01-16T05:59:52.488582649+01:00","block":"another \"testing string\nthat ends here and works\""} +{"datetime":"2023-01-16T06:43:59.230557529+01:00","block":"git \"another\nis another sentence\nnot yet finished\""} +{"datetime":"2023-01-17T03:04:40.384123964+01:00","block":"basic -f \"../another\nwhile that seems to work fine until now\nand that\""} +{"datetime":"2023-01-17T06:00:47.489362454+01:00","block":"basic -f \"commands.go \nanother testing that now works fine !\nanother string\""} +{"datetime":"2023-01-17T06:01:36.637208829+01:00","block":"basic -f \"commands.go \nanother one which works\nfine !\""} +{"datetime":"2023-01-17T06:03:36.41657067+01:00","block":"basic -f \"commands.go \nanother testing\" string that works 'quite\nfine as well !'"} +{"datetime":"2023-01-17T07:58:23.882655383+01:00","block":"basic -f \"commands.go \nanother strings\" -cp=/usr"} +{"datetime":"2023-01-18T03:45:08.09073158+01:00","block":"git \"another\nanother one that \nis another sentence\nthis works\nnot yet finished\""} +{"datetime":"2023-01-18T03:45:32.971677896+01:00","block":"rest-slice-max --help"} +{"datetime":"2023-01-18T07:43:30.869057266+01:00","block":"git \"another\nthis works\nanother one that \nis another sentence\nnot yet finished\""} +{"datetime":"2023-01-18T21:59:52.313129486+01:00","block":"basic -f \"commands.go \nanother strings\" -cp=/usr"} +{"datetime":"2023-01-19T00:17:46.151917638+01:00","block":"basic -f \"commands.go,\nexample,prompt.go,menu.go\n,main.go\""} +{"datetime":"2023-01-19T06:50:53.833642342+01:00","block":"git remote aaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"datetime":"2023-01-19T12:44:25.285698942+01:00","block":"basic -f \"commands.go\nanother\n\n\""} +{"datetime":"2023-01-19T12:48:12.807773+01:00","block":"basic -f \"commands.go\n,interrupt.go \""} +{"datetime":"2023-01-19T12:49:10.734811114+01:00","block":"basic -f \"commands.go \nanother\""} +{"datetime":"2023-01-19T12:58:02.614569021+01:00","block":"basic -f \"commands.go,\ninterrupt.go\""} +{"datetime":"2023-01-19T12:59:34.571425741+01:00","block":"basic -f \"commands.go,\n\ninterrupt.go\""} +{"datetime":"2023-01-19T13:02:43.798614218+01:00","block":"basic -f \"commands.go,\ninterrupt.go\""} +{"datetime":"2023-01-19T13:42:12.956859176+01:00","block":"basic -f \"commands.go \nanother one\nwhich\""} +{"datetime":"2023-01-20T11:55:09.264838127+01:00","block":"basic -f \"commands.go \ncommands\nanother testing\n\ncommand\""} +{"datetime":"2023-01-20T11:56:10.468538573+01:00","block":"git \"another\ntesting that\""} +{"datetime":"2023-01-20T11:58:35.284480928+01:00","block":"basic -f \"commands.go \nanother testing\" --alternate \"another\nquote\" -c"} +{"datetime":"2023-02-18T22:08:05.389983155+01:00","block":"basic -f \"commands.go\nanother\n\n\""} +{"datetime":"2023-02-18T22:08:06.178742474+01:00","block":"basic -f \"commands.go\nanother\n\n\""} +{"datetime":"2023-02-18T22:08:43.562258885+01:00","block":"basic -f \"commands.go \nanother \" --alternate \"another\nquote\" -c"} +{"datetime":"2023-02-19T17:09:36.643284763+01:00","block":"multiple-ambiguous \"192.168.1.1 \ntesting that it works\nfine like this\" and shit like that"} +{"datetime":"2023-02-20T00:48:52.132132415+01:00","block":"multiple-ambiguous \"192.168.1.1 ytesting that it works\nfine like this\" and shit like that"} +{"datetime":"2023-02-20T15:11:17.745843452+01:00","block":"multiple-ambiguous \"192.168.1.1 ytesting that it yfine like this\" and shit like that"} +{"datetime":"2023-02-20T15:11:56.689171231+01:00","block":"multiple-ambiguous \"192.168.1.1 \ntesting that it works\nfine like this\" and shit like that"} +{"datetime":"2023-02-20T15:13:59.604190996+01:00","block":"multiple-ambiguous \"192.168.1.1 ytesting that it yfine like this\" and shit like that"} +{"datetime":"2023-02-20T15:19:02.316732063+01:00","block":"multiple-ambiguous \"192.168.1.1 \ntesting that it works\nfine like this\" and shit like that"} +{"datetime":"2023-02-20T15:19:15.10491494+01:00","block":"git \"another\ntesting that\""} +{"datetime":"2023-02-20T15:19:34.402547081+01:00","block":"multiple-ambiguous \"192.168.1.1 \ntesting that it works\nfine like this\" and shit like that"} +{"datetime":"2023-02-20T15:20:28.885131684+01:00","block":"basic --testing \"that\nit works fine\nand shit\" like this this"} +{"datetime":"2023-02-20T15:20:37.217431158+01:00","block":"basic --testing \"that\nit works fine\nand this\""} +{"datetime":"2023-04-01T21:02:02.10274848+02:00","block":"this is another test \"with\nquotes and shit that works\nfine\nanother string here\nenough like this\" and"} +{"datetime":"2023-04-02T03:51:39.821059309+02:00","block":"this is another \"test\nwith quotes\nand shit\""} +{"datetime":"2023-04-02T03:53:54.899748932+02:00","block":"this is \"another\nanother\none here with\nnewlines\""} +{"datetime":"2023-04-02T04:08:30.666135761+02:00","block":"this is \"another\nanother one here\nthis is a test\ntest with\n\nthat\""} +{"datetime":"2023-04-02T04:09:44.60884763+02:00","block":"this is \"another\nanother here working fine\ntest with\nenter\nand this works two\""} +{"datetime":"2023-04-02T04:11:50.459688807+02:00","block":"this is \"another\nwhich works fine\nand that\n\ntest with\""} +{"datetime":"2023-04-02T04:20:12.92327187+02:00","block":"y"} +{"datetime":"2023-04-02T05:08:04.236419813+02:00","block":"this is \"another test\nwith that and this\nwith that and this\nwith that and this\nwith thiswith that and this\""} +{"datetime":"2023-04-02T05:15:17.060884936+02:00","block":"another"} +{"datetime":"2023-04-02T05:21:35.868740254+02:00","block":"testing that buffers work"} +{"datetime":"2023-04-02T05:22:25.58063695+02:00","block":"testing that \"buffers work \nfine and that we can paste from\nthem\n\nor not\""} +{"datetime":"2023-04-02T21:12:07.628306536+02:00","block":"this test is \"for visual selection\" and surround-type \"shellwords 'with nested' quoting\""} +{"datetime":"2023-04-04T01:43:26.709922729+02:00","block":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"datetime":"2023-04-04T08:38:21.242637441+02:00","block":"this is another \"test\" # with comments\n# This is also a comment"} +{"datetime":"2023-04-04T09:01:05.451164569+02:00","block":"this is another \"test\" # With comments\n# And here is another comment\nwhile \"this is another command\" # With a side comment\nwhile this is another one"} +{"datetime":"2023-04-04T21:17:53.276423761+02:00","block":"ls /home/user"} +{"datetime":"2023-04-05T23:54:15.612161067+02:00","block":"this is another test which"} +{"datetime":"2023-04-05T23:55:48.913998456+02:00","block":"this is another another test which does not work and exec"} +{"datetime":"2023-04-09T03:02:34.313724755+02:00","block":"basic -f ../console.wiki/Expansion-Completers.md -c -p/usr"} +{"datetime":"2023-04-09T19:01:18.289852268+02:00","block":"basic -f ~/code/github.com/reeflective/console/console.wiki/Expansion-Completers.md"} +{"datetime":"2023-04-09T23:50:41.16452954+02:00","block":"this is another another test which does not work and execki/Expansion-Completers.md"} +{"datetime":"2023-04-10T02:13:34.688318916+02:00","block":"basic --email @mail.com"} +{"datetime":"2023-04-11T00:32:02.218662759+02:00","block":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +{"datetime":"2023-04-11T10:56:02.207397066+02:00","block":"basic --email user@mail.com --alternate example -cp=/dev/"} +{"datetime":"2023-04-11T10:56:33.771495959+02:00","block":"basic --email user@mail.com --alternate example -cp"} +{"datetime":"2023-04-15T20:21:04.926783918+02:00","block":"basic --files ../example/"} +{"datetime":"2023-04-16T21:39:54.14110427+02:00","block":"multiple-ambiguous 10.203.23.45 127.0.0.1 added here"} +{"datetime":"2023-04-16T21:41:23.263265233+02:00","block":"basic -p=/usr/"} +{"datetime":"2023-04-23T19:55:59.373326369+02:00","block":"git testing \"this\nworks fine and it does\""} +{"datetime":"2023-04-23T22:00:08.494805407+02:00","block":"multiple-ambiguous 10.203.23.45 127.0.0.1"} +{"datetime":"2023-04-25T17:27:00.490171002+02:00","block":"basic --alternate"} +{"datetime":"2023-04-25T17:41:42.66481256+02:00","block":"this is another test"} +{"datetime":"2023-04-28T04:42:24.985114824+02:00","block":"this is a test"} +{"datetime":"2023-04-29T06:24:01.496183686+02:00","block":"this another \"another one word\" and different-words for yanking 'and deleting' stuff"} +{"datetime":"2023-05-01T11:06:29.331107357+02:00","block":"basic --alternat"} +{"datetime":"2023-05-01T16:42:35.580460165+02:00","block":"git this is http://domain.word.com/url?key=value\u0026test=val,testingthis this word"} +{"datetime":"2023-05-01T20:59:48.621791526+02:00","block":"git \"testing this\n\tanother string here\""} +{"datetime":"2023-05-02T11:56:15.994834787+02:00","block":"client"} +{"datetime":"2023-05-02T13:42:35.174213031+02:00","block":"this\ttesting that\tanother"} +{"datetime":"2023-05-02T17:15:28.198885749+02:00","block":"git this is http://domain.word.com/url?key=value\u0026test=val,testingthis this word http://10.203.23.45:3999 127.0.0.1"} +{"datetime":"2023-05-03T11:53:52.615117505+02:00","block":"basic --alternate test --email"} +{"datetime":"2023-05-05T12:33:35.279499069+02:00","block":"git this is http://domain.word.com/url?key=value\u0026test=val,testingthis this word http://10.203.23.45:3999 127.0.0.1"} +{"datetime":"2023-05-06T10:33:54.933490702+02:00","block":"valid-flags --elems"} +{"datetime":"2023-05-07T08:28:09.323517912+02:00","block":"this is [ another ] test"} +{"datetime":"2023-05-07T09:31:36.446616828+02:00","block":"this is { another } test \"another\""} +{"datetime":"2023-05-17T02:36:56.221339513+02:00","block":"help help"} +{"datetime":"2023-05-17T02:36:59.298099991+02:00","block":"help client"} +{"datetime":"2023-05-17T02:37:04.911736647+02:00","block":"help ignored"} +{"datetime":"2023-05-17T02:49:26.611062064+02:00","block":"help client"} +{"datetime":"2023-05-17T15:11:48.33711594+02:00","block":"this is a test line saved to history"} +{"datetime":"2023-05-17T15:16:05.987285843+02:00","block":"client"} +{"datetime":"2023-05-17T15:26:07.718906666+02:00","block":"another test"} diff --git a/console/example/.gitignore b/console/example/.gitignore new file mode 100644 index 0000000..f981b31 --- /dev/null +++ b/console/example/.gitignore @@ -0,0 +1,3 @@ +# Console example binary +example +example.exe diff --git a/console/example/README.md b/console/example/README.md new file mode 100644 index 0000000..5ee3aac --- /dev/null +++ b/console/example/README.md @@ -0,0 +1,34 @@ + +## Summary +This directory contains an example console application containing: +- Two different command menus +- Each with their own prompt engine +- The first menu contains the complete command set from the [reeflective/flags/example](https://github.com/reeflective/flags/tree/main/example) CLI. +- (The other one is empty) +- Special interrupt handlers to switch back and forth between menus. + +## Installing +Assuming that you have a working Go toolchain: +```bash +go install github.com/chainreactors/tui/console/example +``` + + + + + + + + +## Directories and files +The files/directories below are listed in the order in which a user would want to +read them to fully understand how to use the various features of this library. +Note that these files are also the ones used as demonstration snippets in the [wiki](https://github.com/chainreactors/tui/console/wiki). + +- `main.go` - The entrypoint where all our bindings functions are called, and the application is run. +- `menu.go` - In here, we create a new menu, and bind some various stuff to it. +- `commands.go` - Here we generate and bind our cobra command tree to one of the menus. +- `interrupt.go` - Declares some special interrupt handlers to be used on certain keystrokes. +- `.example-history` - A history file used as a source of command history, embedded in the binary. + + diff --git a/console/example/feature-commands.go b/console/example/feature-commands.go new file mode 100644 index 0000000..fe0fc6b --- /dev/null +++ b/console/example/feature-commands.go @@ -0,0 +1,213 @@ +package main + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/carapace-sh/carapace" + "github.com/spf13/cobra" + + "github.com/chainreactors/tui/console" +) + +// featureGroupID groups the commands that demonstrate the readline hint and +// async-completion features. +const featureGroupID = "readline" + +// setupReadlineHints registers a passive hint provider on the shell. The +// provider is recomputed from the current input line on every refresh and its +// result is shown below the input, in the dedicated "provided" hint lane. +// +// Here it resolves the command being typed and shows its short description. +// Because this lane is independent from completion hints (set by the completion +// engine) and from transient/async status messages (see the `notify`/`hint` +// commands), all three can be displayed at once without clobbering each other. +func setupReadlineHints(app *console.Console) { + dim := func(format string, args ...any) []rune { + return []rune("\x1b[2;3m" + fmt.Sprintf(format, args...) + "\x1b[0m") + } + + app.Shell().Hint.SetProvider(func(line []rune, _ int) []rune { + fields := strings.Fields(string(line)) + if len(fields) == 0 { + return dim("type a command — try 'notify', 'hint set ...', or 'scan '") + } + + menu := app.ActiveMenu() + if menu == nil || menu.Command == nil { + return nil + } + + // Find resolves the deepest command matched by the words typed so far. + cmd, _, err := menu.Find(fields) + if err != nil || cmd == nil || cmd == menu.Command { + return nil + } + + return dim("%s — %s", cmd.CommandPath(), cmd.Short) + }) +} + +// readlineFeatureCommands builds the commands demonstrating the hint lanes and +// async completion regeneration. They are added to the main menu. +func readlineFeatureCommands(app *console.Console) []*cobra.Command { + return []*cobra.Command{ + notifyCommand(app), + hintCommand(app), + scanCommand(app), + } +} + +// notifyCommand demonstrates ASYNC status updates in the transient hint lane. +// It starts a background job that pushes status messages from another goroutine +// with Hint.SetTransient; the shell repaints on its own (no keystroke), thanks +// to the async-refresh wake. +func notifyCommand(app *console.Console) *cobra.Command { + return &cobra.Command{ + Use: "notify", + Short: "Async status updates shown in the hint lane (transient hint + wake)", + GroupID: featureGroupID, + Run: func(_ *cobra.Command, _ []string) { + hint := app.Shell().Hint + stages := []string{ + "\x1b[33m⠋ connectingâ€Ļ\x1b[0m", + "\x1b[33m⠙ authenticatingâ€Ļ\x1b[0m", + "\x1b[33mâ š transferringâ€Ļ\x1b[0m", + "\x1b[32m✓ transfer complete\x1b[0m", + } + + go func() { + for _, stage := range stages { + time.Sleep(1200 * time.Millisecond) + hint.SetTransient(stage) + } + + time.Sleep(1500 * time.Millisecond) + hint.ClearTransient() + }() + + fmt.Println("Background job started — watch the hint line below the prompt update on its own (no keystroke needed).") + }, + } +} + +// hintCommand demonstrates SYNCHRONOUS use of the transient hint lane: setting a +// sticky status message that persists across keystrokes (unlike a completion +// hint) until it is cleared or replaced. +func hintCommand(app *console.Console) *cobra.Command { + hint := &cobra.Command{ + Use: "hint", + Short: "Set or clear a sticky transient hint immediately (non-async)", + GroupID: featureGroupID, + } + + hint.AddCommand(&cobra.Command{ + Use: "set MESSAGE...", + Short: "Set the transient hint lane to a message (persists until cleared)", + Args: cobra.MinimumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + app.Shell().Hint.SetTransient("\x1b[36m" + strings.Join(args, " ") + "\x1b[0m") + }, + }) + + hint.AddCommand(&cobra.Command{ + Use: "clear", + Short: "Clear the transient hint lane", + Run: func(_ *cobra.Command, _ []string) { + app.Shell().Hint.ClearTransient() + }, + }) + + return hint +} + +// hostDiscovery is a process-wide singleton: the console rebuilds its command +// tree (and thus re-runs scanCommand) on each completion, so the discovery state +// must persist across those rebuilds rather than being recreated each time. +// +// Seeded with two known hosts so the menu opens and stays open — a single +// candidate would be auto-accepted, closing the menu before any async result +// could be shown. +var hostDiscovery = &discovery{base: []string{"localhost", "gateway"}} + +// scanCommand demonstrates ASYNC completions. Its argument completer returns a +// set of hosts that a background "discovery" grows over time; each time a host +// is found, the goroutine calls Shell().RefreshCompletions(), which rebuilds the +// already-open completion menu in place — so hosts appear live while the menu +// stays open, with no keystroke from the user. +func scanCommand(app *console.Console) *cobra.Command { + scan := &cobra.Command{ + Use: "scan [HOST]", + Short: "Async completions — press Tab after 'scan ' and watch hosts appear live", + GroupID: featureGroupID, + Args: cobra.MaximumNArgs(1), + Run: func(_ *cobra.Command, args []string) { + if len(args) == 0 { + fmt.Println("Usage: scan HOST (press Tab after 'scan ' and watch the menu fill in)") + return + } + + fmt.Println("Scanning host:", args[0]) + }, + } + + carapace.Gen(scan).PositionalCompletion( + carapace.ActionCallback(func(_ carapace.Context) carapace.Action { + hostDiscovery.start(app) + return carapace.ActionValues(hostDiscovery.snapshot()...) + }), + ) + + return scan +} + +// discovery simulates an asynchronous completion producer: a background routine +// appends "discovered" hosts to a cache and asks the shell to regenerate the +// open menu in place. +type discovery struct { + mu sync.Mutex + base []string + found []string + running bool +} + +// snapshot returns the current known + discovered hosts. +func (d *discovery) snapshot() []string { + d.mu.Lock() + defer d.mu.Unlock() + + return append(append([]string{}, d.base...), d.found...) +} + +// start kicks off one discovery run if none is in progress. Each newly found +// host triggers an in-place regeneration of the open completion menu. +func (d *discovery) start(app *console.Console) { + d.mu.Lock() + if d.running { + d.mu.Unlock() + return + } + + d.running = true + d.found = nil + d.mu.Unlock() + + go func() { + for i := 1; i <= 8; i++ { + time.Sleep(900 * time.Millisecond) + + d.mu.Lock() + d.found = append(d.found, fmt.Sprintf("10.0.0.%d", i)) + d.mu.Unlock() + + // Rebuild the open menu in place with the newly discovered host. + app.Shell().RefreshCompletions() + } + + d.mu.Lock() + d.running = false + d.mu.Unlock() + }() +} diff --git a/console/example/history.go b/console/example/history.go new file mode 100644 index 0000000..00a25e9 --- /dev/null +++ b/console/example/history.go @@ -0,0 +1,132 @@ +package main + +import ( + "bufio" + "embed" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/chainreactors/tui/readline" +) + +//go:embed .example-history +var historyFile embed.FS + +var ( + errOpenHistoryFile = errors.New("failed to open history file") + errNegativeIndex = errors.New("cannot use a negative index when requesting historic commands") + errOutOfRangeIndex = errors.New("index requested greater than number of items in history") +) + +type fileHistory struct { + file string + lines []Item +} + +type Item struct { + Index int + DateTime time.Time + Block string +} + +// NewSourceFromFile returns a new history source writing to and reading from a file. +func embeddedHistory(file string) (readline.History, error) { + var err error + + hist := new(fileHistory) + hist.file = file + hist.lines, err = openHist(file) + + return hist, err +} + +func openHist(filename string) (list []Item, err error) { + file, err := historyFile.Open(filename) + if err != nil { + return list, fmt.Errorf("error opening history file: %s", err.Error()) + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var item Item + + err := json.Unmarshal(scanner.Bytes(), &item) + if err != nil || len(item.Block) == 0 { + continue + } + + item.Index = len(list) + list = append(list, item) + } + + file.Close() + + return list, nil +} + +// Write item to history file. +func (h *fileHistory) Write(s string) (int, error) { + block := strings.TrimSpace(s) + if block == "" { + return 0, nil + } + + item := Item{ + DateTime: time.Now(), + Block: block, + Index: len(h.lines), + } + + if len(h.lines) == 0 || h.lines[len(h.lines)-1].Block != block { + h.lines = append(h.lines, item) + } + + // line := struct { + // DateTime time.Time `json:"datetime"` + // Block string `json:"block"` + // }{ + // Block: block, + // DateTime: item.DateTime, + // } + // + // data, err := json.Marshal(line) + // if err != nil { + // return h.Len(), err + // } + // + // f, err := historyFile.Open(h.file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + // if err != nil { + // return 0, fmt.Errorf("%w: %s", errOpenHistoryFile, err.Error()) + // } + // + // _, err = f.Write(append(data, '\n')) + // f.Close() + + return h.Len(), nil +} + +// GetLine returns a specific line from the history file. +func (h *fileHistory) GetLine(pos int) (string, error) { + if pos < 0 { + return "", errNegativeIndex + } + + if pos < len(h.lines) { + return h.lines[pos].Block, nil + } + + return "", errOutOfRangeIndex +} + +// Len returns the number of items in the history file. +func (h *fileHistory) Len() int { + return len(h.lines) +} + +// Dump returns the entire history file. +func (h *fileHistory) Dump() interface{} { + return h.lines +} diff --git a/console/example/interrupt.go b/console/example/interrupt.go new file mode 100644 index 0000000..83e72c0 --- /dev/null +++ b/console/example/interrupt.go @@ -0,0 +1,30 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/chainreactors/tui/console" +) + +// exitCtrlD is a custom interrupt handler to use when the shell +// readline receives an io.EOF error, which is returned with CtrlD. +func exitCtrlD(c *console.Console) { + reader := bufio.NewReader(os.Stdin) + + fmt.Print("Confirm exit (Y/y): ") + + text, _ := reader.ReadString('\n') + answer := strings.TrimSpace(text) + + if (answer == "Y") || (answer == "y") { + os.Exit(0) + } +} + +func switchMenu(c *console.Console) { + fmt.Println("Switching to client menu") + c.SwitchMenu("client") +} diff --git a/console/example/main-commands.go b/console/example/main-commands.go new file mode 100644 index 0000000..8abc4eb --- /dev/null +++ b/console/example/main-commands.go @@ -0,0 +1,626 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/carapace-sh/carapace" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/chainreactors/tui/console" + "github.com/chainreactors/tui/console/commands/readline" +) + +// mainMenuCommands - Create the commands for the main menu. +// Most of these commands have an empty implementation, and all +// have been generated with ChatGPT prompts. +func mainMenuCommands(app *console.Console) console.Commands { + return func() *cobra.Command { + rootCmd := &cobra.Command{} + rootCmd.Short = shortUsage + + rootCmd.AddGroup( + &cobra.Group{ID: "core", Title: "core"}, + &cobra.Group{ID: "filesystem", Title: "filesystem"}, + &cobra.Group{ID: "deployment", Title: "deployment"}, + &cobra.Group{ID: "tools", Title: "tools"}, + &cobra.Group{ID: featureGroupID, Title: "readline features"}, + ) + + // Readline subcommands + rootCmd.AddCommand(readline.Commands(app.Shell())) + + exitCmd := &cobra.Command{ + Use: "exit", + Short: "Exit the console application", + GroupID: "core", + Run: func(cmd *cobra.Command, args []string) { + exitCtrlD(app) + }, + } + rootCmd.AddCommand(exitCmd) + + // And let's add a command declared in a traditional "cobra" way. + clientMenuCommand := &cobra.Command{ + Use: "client", + Short: "Switch to the client menu (also works with CtrlC)", + GroupID: "core", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Switching to client menu") + app.SwitchMenu("client") + }, + } + rootCmd.AddCommand(clientMenuCommand) + + helloCmd := &cobra.Command{ + Use: "hello", + Short: "Say hello with customizable message", + GroupID: "core", + Run: func(cmd *cobra.Command, args []string) { + // This is the implementation logic for the hello command. + message, _ := cmd.Flags().GetString("message") + count, _ := cmd.Flags().GetInt("count") + + for range count { + fmt.Println(message) + } + }, + } + + // Add flags to the hello command + helloCmd.Flags().String("message", "Hello, World!", "Customize the greeting message") + helloCmd.Flags().Int("count", 1, "Number of times to print the message") + + // Add the hello command as a subcommand of the root command + rootCmd.AddCommand(helloCmd) + + greetCmd := &cobra.Command{ + Use: "greet", + Short: "Greet a person", + GroupID: "core", + Run: func(cmd *cobra.Command, args []string) { + name, _ := cmd.Flags().GetString("name") + age, _ := cmd.Flags().GetInt("age") + + fmt.Printf("Hello, %s! You are %d years old.\n", name, age) + }, + } + + greetCmd.Flags().String("name", "", "Specify a name to greet") + greetCmd.Flags().Int("age", 0, "Specify the age of the person") + + rootCmd.AddCommand(greetCmd) + + convertCmd := &cobra.Command{ + Use: "convert", + Short: "Convert a file", + GroupID: "filesystem", + Run: func(cmd *cobra.Command, args []string) { + inputFile, _ := cmd.Flags().GetString("input") + outputFile, _ := cmd.Flags().GetString("output") + appendMode, _ := cmd.Flags().GetBool("append") + + fmt.Printf("Converting file: %s\n", inputFile) + fmt.Printf("Output file: %s\n", outputFile) + + if appendMode { + fmt.Println("Append mode: ON") + } else { + fmt.Println("Append mode: OFF") + } + }, + } + + convertCmd.Flags().String("input", "", "Specify the input file") + convertCmd.Flags().String("output", "", "Specify the output file") + convertCmd.Flags().Bool("append", false, "Enable append mode") + + rootCmd.AddCommand(convertCmd) + + mkdirCmd := &cobra.Command{ + Use: "mkdir [flags] DIRECTORY...", + Short: "Create directories", + GroupID: "filesystem", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + verbose, _ := cmd.Flags().GetBool("verbose") + parents, _ := cmd.Flags().GetBool("parents") + + for _, dir := range args { + var err error + if parents { + err = os.MkdirAll(dir, os.ModePerm) + } else { + err = os.Mkdir(dir, os.ModePerm) + } + + if err != nil { + fmt.Printf("Error creating directory: %s\n", err) + } else if verbose { + fmt.Printf("Created directory: %s\n", dir) + } + } + }, + } + + mkdirCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + mkdirCmd.Flags().BoolP("parents", "p", false, "Make parent directories as needed") + rootCmd.AddCommand(mkdirCmd) + + lsCmd := &cobra.Command{ + Use: "ls [flags] [DIRECTORY]", + Short: "List directory contents", + GroupID: "filesystem", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + directory := "./" + if len(args) > 0 { + directory = args[0] + } + + fmt.Println("Running ls command with directory:", directory) + + // Implementation logic for ls command + // Customize or extend the logic as needed + }, + } + + lsCmd.Flags().BoolP("long", "l", false, "Use a long listing format") + lsCmd.Flags().Bool("human-readable", false, "Print sizes in human-readable format") + lsCmd.Flags().Bool("all", false, "Do not ignore entries starting with .") + lsCmd.Flags().Bool("recursive", false, "List subdirectories recursively") + lsCmd.Flags().Bool("hidden", false, "Show hidden files") + lsCmd.Flags().Bool("sort-by-size", false, "Sort by file size") + lsCmd.Flags().Bool("sort-by-time", false, "Sort by modification time") + lsCmd.Flags().Bool("reverse", false, "Reverse order while sorting") + rootCmd.AddCommand(lsCmd) + + sshCmd := &cobra.Command{ + Use: "ssh [flags] USER@HOST", + Short: "SSH client", + GroupID: "tools", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + userHost := args[0] + + identityFile, _ := cmd.Flags().GetString("identity") + port, _ := cmd.Flags().GetString("port") + verbose, _ := cmd.Flags().GetBool("verbose") + + sshArgs := []string{"-l", userHost} + if identityFile != "" { + sshArgs = append(sshArgs, "-i", identityFile) + } + if port != "" { + sshArgs = append(sshArgs, "-p", port) + } + + sshArgs = append(sshArgs, "echo", "Hello, SSH!") + + sshCmd := exec.Command("ssh", sshArgs...) + + if verbose { + fmt.Println("Executing SSH command:", strings.Join(sshCmd.Args, " ")) + } + + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + err := sshCmd.Run() + if err != nil { + fmt.Printf("SSH command failed: %s\n", err) + os.Exit(1) + } + }, + } + + sshCmd.Flags().String("identity", "", "Specify the identity file") + sshCmd.Flags().String("port", "", "Specify the SSH port") + sshCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + rootCmd.AddCommand(sshCmd) + + gitCmd := &cobra.Command{ + Use: "git [flags] ", + Short: "Git command", + GroupID: "tools", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Git command invoked without a specific subcommand") + cmd.Usage() + }, + } + rootCmd.AddCommand(gitCmd) + + cloneCmd := &cobra.Command{ + Use: "clone REPO_URL [DESTINATION]", + Short: "Clone a repository", + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + repoURL := args[0] + destination := "./" + if len(args) == 2 { + destination = args[1] + } + + fmt.Printf("Cloning repository: %s to %s\n", repoURL, destination) + + gitArgs := []string{"clone", repoURL, destination} + + gitCmd := exec.Command("git", gitArgs...) + gitCmd.Stdout = os.Stdout + gitCmd.Stderr = os.Stderr + + err := gitCmd.Run() + if err != nil { + fmt.Printf("Git clone failed: %s\n", err) + os.Exit(1) + } + }, + } + + cloneCmd.Flags().StringP("branch", "b", "", "Checkout a specific branch") + cloneCmd.Flags().Bool("bare", false, "Create a bare repository") + + gitCmd.AddCommand(cloneCmd) + + checkoutCmd := &cobra.Command{ + Use: "checkout [flags] [BRANCH]", + Short: "Switch branches or restore working tree files", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + // Default behavior, print help message + cmd.Help() + return + } + + branch := args[0] + + // Implementation logic for git checkout command + + // Customize or extend the logic as needed + + fmt.Println("Running git checkout command with branch:", branch) + }, + } + + checkoutCmd.Flags().BoolP("force", "f", false, "Force checkout") + checkoutCmd.Flags().BoolP("create", "b", false, "Create and checkout a new branch") + gitCmd.AddCommand(checkoutCmd) + + commitCmd := &cobra.Command{ + Use: "commit [flags]", + Short: "Record changes to the repository", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Running git commit command") + + // Implementation logic for git commit command + + // Customize or extend the logic as needed + }, + } + + commitCmd.Flags().StringP("message", "m", "", "Commit message") + commitCmd.Flags().Bool("amend", false, "Amend the previous commit") + gitCmd.AddCommand(commitCmd) + + pullCmd := &cobra.Command{ + Use: "pull [flags]", + Short: "Fetch from and integrate with another repository or a local branch", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Running git pull command") + + // Implementation logic for git pull command + + // Customize or extend the logic as needed + }, + } + + pullCmd.Flags().String("rebase", "", "Rebase local branch onto fetched branch") + gitCmd.AddCommand(pullCmd) + + pushCmd := &cobra.Command{ + Use: "push [flags]", + Short: "Update remote refs along with associated objects", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Running git push command") + + // Implementation logic for git push command + + // Customize or extend the logic as needed + }, + } + + pushCmd.Flags().BoolP("force", "f", false, "Force push") + pushCmd.Flags().Bool("tags", false, "Push tags") + + // Add more flags or logic as needed + gitCmd.AddCommand(pushCmd) + + downloadCmd := &cobra.Command{ + Use: "download [flags] URL DESTINATION", + Short: "Download a file from a URL", + GroupID: "filesystem", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + url := args[0] + destination := args[1] + + // Implementation logic for download command + + // Customize or extend the logic as needed + + fmt.Printf("Downloading file from URL: %s to destination: %s\n", url, destination) + }, + } + + downloadCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + downloadCmd.Flags().StringP("user-agent", "u", "", "Set the User-Agent header") + rootCmd.AddCommand(downloadCmd) + + encryptCmd := &cobra.Command{ + Use: "encrypt [flags] FILE", + Short: "Encrypt a file", + GroupID: "filesystem", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + file := args[0] + + // Implementation logic for encrypt command + + // Customize or extend the logic as needed + + fmt.Println("Encrypting file:", file) + }, + } + + encryptCmd.Flags().StringP("algorithm", "a", "aes256", "Set the encryption algorithm") + encryptCmd.Flags().BoolP("force", "f", false, "Force encryption") + encryptCmd.Flags().StringP("key", "k", "", "Specify the encryption key") + encryptCmd.Flags().StringP("output", "o", "", "Specify the output file") + + rootCmd.AddCommand(encryptCmd) + + searchCmd := &cobra.Command{ + Use: "search [flags] QUERY", + Short: "Search for a query", + GroupID: "filesystem", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + query := args[0] + + // Implementation logic for search command + + // Customize or extend the logic as needed + + fmt.Println("Searching for query:", query) + }, + } + searchCmd.Flags().BoolP("case-sensitive", "c", false, "Perform case-sensitive search") + searchCmd.Flags().BoolP("regex", "r", false, "Interpret the query as a regular expression") + searchCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + + rootCmd.AddCommand(searchCmd) + + mySlice := []string{"a", "b", "c"} + + backupCmd := &cobra.Command{ + Use: "backup [flags] SOURCE DESTINATION", + Short: "Create a backup of a file or directory", + GroupID: "filesystem", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + source := args[0] + destination := args[1] + + flagsVal, _ := cmd.Flags().GetStringSlice("test-slice") + + // Implementation logic for backup command + fmt.Printf("mySlice: %v\n", mySlice) + fmt.Printf("mySlice flags: %v\n", flagsVal) + fmt.Printf("mySlice flags length: %v\n", len(flagsVal)) + fmt.Printf("mySlice length: %v\n", len(mySlice)) + + fmt.Printf("Creating backup of %s to %s\n", source, destination) + }, + } + + backupCmd.Flags().BoolP("incremental", "i", false, "Perform incremental backup") + backupCmd.Flags().StringP("compression", "c", "gzip", "Specify the compression algorithm") + backupCmd.Flags().Bool("dry-run", false, "Perform a dry run without actually creating the backup") + backupCmd.Flags().StringSliceVarP(&mySlice, "test-slice", "T", mySlice, "Testing the shit") + rootCmd.AddCommand(backupCmd) + + renameCmd := &cobra.Command{ + Use: "rename [flags] FILE NEW_NAME", + Short: "Rename a file", + GroupID: "filesystem", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + file := args[0] + newName := args[1] + + // Implementation logic for rename command + + fmt.Printf("Renaming file %s to %s\n", file, newName) + }, + } + + renameCmd.Flags().BoolP("force", "f", false, "Force rename, even if the new name already exists") + renameCmd.Flags().BoolP("preserve-extension", "p", false, "Preserve the file extension while renaming") + + rootCmd.AddCommand(renameCmd) + + deployCmd := &cobra.Command{ + Use: "deploy [flags] FILE", + Short: "Deploy a file", + GroupID: "deployment", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + file := args[0] + + // Implementation logic for deploy command + + fmt.Println("Deploying file:", file) + }, + } + + deployCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + deployCmd.Flags().StringP("target", "t", "", "Specify the deployment target") + deployCmd.Flags().Bool("clean", false, "Perform a clean deployment, removing previous versions") + + rootCmd.AddCommand(deployCmd) + + deployWebCmd := &cobra.Command{ + Use: "web [flags]", + Short: "Deploy a web application", + Run: func(cmd *cobra.Command, args []string) { + // Implementation logic for deploying a web application + + fmt.Println("Deploying web application") + }, + } + + deployCmd.AddCommand(deployWebCmd) + + deployAPICmd := &cobra.Command{ + Use: "api [flags]", + Short: "Deploy an API service", + Run: func(cmd *cobra.Command, args []string) { + // Implementation logic for deploying an API service + + fmt.Println("Deploying API service") + }, + } + + deployCmd.AddCommand(deployAPICmd) + + deployDatabaseCmd := &cobra.Command{ + Use: "database [flags]", + Short: "Deploy a database", + Run: func(cmd *cobra.Command, args []string) { + // Implementation logic for deploying a database + + fmt.Println("Deploying database") + }, + } + + deployCmd.AddCommand(deployDatabaseCmd) + + localCmd := &cobra.Command{ + Use: "local [flags] FILE", + Short: "Deploy a file locally", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + file := args[0] + + // Implementation logic for local subcommand + + fmt.Println("Deploying file locally:", file) + }, + } + + localCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + + deployCmd.AddCommand(localCmd) + + remoteCmd := &cobra.Command{ + Use: "remote [flags] FILE", + Short: "Deploy a file remotely", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + file := args[0] + + // Implementation logic for remote subcommand + + fmt.Println("Deploying file remotely:", file) + }, + } + + remoteCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + remoteCmd.Flags().StringP("host", "h", "", "Specify the remote host") + + deployCmd.AddCommand(remoteCmd) + + cloudCmd := &cobra.Command{ + Use: "cloud [flags] FILE", + Short: "Deploy a file to the cloud", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + file := args[0] + + // Implementation logic for cloud subcommand + + fmt.Println("Deploying file to the cloud:", file) + }, + } + + cloudCmd.Flags().BoolP("verbose", "v", false, "Print verbose output") + cloudCmd.Flags().StringP("provider", "p", "", "Specify the cloud provider") + + deployCmd.AddCommand(cloudCmd) + + // + // Completions ----------------------------------------------------------------- // + // + + // For each of the commands above, generate the carapace.Carapace for the command. + // Then create a map carapace.FlagMap, and add file completion to all flags requiring + // a file argument. + for _, cmd := range rootCmd.Commands() { + c := carapace.Gen(cmd) + + if cmd.Args != nil { + c.PositionalAnyCompletion( + carapace.ActionCallback(func(c carapace.Context) carapace.Action { + return carapace.ActionFiles() + }), + ) + } + + flagMap := make(carapace.ActionMap) + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Name == "file" || strings.Contains(f.Usage, "file") { + flagMap[f.Name] = carapace.ActionFiles() + } + }) + + if cmd.Name() == "ssh" { + // Generate a list of random hosts to use as positional arguments + hosts := make([]string, 0) + for i := range 10 { + hosts = append(hosts, fmt.Sprintf("host%d", i)) + } + + c.PositionalCompletion(carapace.ActionValues(hosts...)) + } + + if cmd.Name() == "encrypt" { + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Name == "algorithm" { + flagMap[f.Name] = carapace.ActionValues("aes", "des", "blowfish") + } + }) + } + + c.FlagCompletion(flagMap) + } + + // Add the readline-feature demo commands AFTER the generic completion + // loop above, so their custom (e.g. async) completers are not replaced + // by the default file completion applied to every command with args. + for _, cmd := range readlineFeatureCommands(app) { + rootCmd.AddCommand(cmd) + } + + rootCmd.SetHelpCommandGroupID("core") + rootCmd.InitDefaultHelpCmd() + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.DisableFlagsInUseLine = true + + return rootCmd + } +} diff --git a/console/example/main.go b/console/example/main.go new file mode 100644 index 0000000..ccd45ac --- /dev/null +++ b/console/example/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "io" + + "github.com/chainreactors/tui/console" +) + +const ( + shortUsage = "Console application example, with cobra commands/flags/completions generated from structs" +) + +func main() { + // Instantiate a new app, with a single, default menu. + // All defaults are set, and nothing is needed to make it work. + app := console.New("example") + + // Global Setup ------------------------------------------------- // + app.NewlineBefore = true + app.NewlineAfter = true + + app.SetPrintLogo(func(_ *console.Console) { + fmt.Print(` + _____ __ _ _ _ _____ _ + | __ \ / _| | | | (_) / ____| | | + | |__) |___ ___| |_| | ___ ___| |_ ___ _____ | | ___ _ __ ___ ___ | | ___ + | _ // _ \/ _ \ _| |/ _ \/ __| __| \ \ / / _ \ | | / _ \| '_ \/ __|/ _ \| |/ _ \ + | | \ \ __/ __/ | | | __/ (__| |_| |\ V / __/ | |___| (_) | | | \__ \ (_) | | __/ + |_| \_\___|\___|_| |_|\___|\___|\__|_| \_/ \___| \_____\___/|_| |_|___/\___/|_|\___| + +`) + }) + + // Main Menu Setup ---------------------------------------------- // + + // By default the shell as created a single menu and + // made it current, so you can access it and set it up. + menu := app.ActiveMenu() + + // Set some custom prompt handlers for this menu. + setupPrompt(menu) + + // Register a passive hint provider on the shell, demonstrating the readline + // hint lanes (passive provider / async transient / completion hints). + setupReadlineHints(app) + + // All menus currently each have a distinct, in-memory history source. + // Replace the main (current) menu's history with one writing to our + // application history file. The default history is named after its menu. + hist, _ := embeddedHistory(".example-history") + menu.AddHistorySource("local history", hist) + + // We bind a special handler for this menu, which will exit the + // application (with confirm), when the shell readline receives + // a Ctrl-D keystroke. You can map any error to any handler. + menu.AddInterrupt(io.EOF, exitCtrlD) + + // Make a command yielder for our main menu. + // menu.SetCommands(makeflagsCommands(app)) + // Thanks ChatGPT for generating this for us! + menu.SetCommands(mainMenuCommands(app)) + + // Client Menu Setup -------------------------------------------- // + + // Create another menu, different from the main one. + // It will have its own command tree, prompt engine, history sources, etc. + clientMenu := app.NewMenu("client") + + // Here, for the sake of demonstrating custom interrupt + // handlers and for sparing use to write a dedicated command, + // we use a custom interrupt handler to switch back to main menu. + clientMenu.AddInterrupt(io.EOF, errorCtrlSwitchMenu) + + // Add some commands to our client menu. + // This is an example of binding "traditionally defined" cobra.Commands. + clientMenu.SetCommands(makeClientCommands(app)) + + // Run the app -------------------------------------------------- // + + // Everything is ready for a tour. + // Run the console and take a look around. + app.Start() +} diff --git a/console/example/menu.go b/console/example/menu.go new file mode 100644 index 0000000..b58ca05 --- /dev/null +++ b/console/example/menu.go @@ -0,0 +1,172 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/spf13/cobra" + + "github.com/chainreactors/tui/console" +) + +// In here we create some menus which hold different command trees. +func createMenus(c *console.Console) { + clientMenu := c.NewMenu("client") + + // Here, for the sake of demonstrating custom interrupt + // handlers and for sparing use to write a dedicated command, + // we use a custom interrupt handler to switch back to main menu. + clientMenu.AddInterrupt(io.EOF, errorCtrlSwitchMenu) + + // Add some commands to our client menu. + // This is an example of binding "traditionally defined" cobra.Commands. + clientMenu.SetCommands(makeClientCommands(c)) +} + +// errorCtrlSwitchMenu is a custom interrupt handler which will +// switch back to the main menu when the current menu receives +// a CtrlD (io.EOF) error. +func errorCtrlSwitchMenu(c *console.Console) { + fmt.Println("Switching back to main menu") + c.SwitchMenu("") +} + +// A little set of commands for the client menu, (wrapped so that +// we can pass the console to them, because the console is local). +func makeClientCommands(app *console.Console) console.Commands { + return func() *cobra.Command { + root := &cobra.Command{} + + ticker := &cobra.Command{ + Use: "ticker", + Short: "Triggers some asynchronous notifications to the shell, demonstrating async logging", + Run: func(cmd *cobra.Command, args []string) { + menu := app.ActiveMenu() + timer := time.Tick(2 * time.Second) + messages := []string{ + "Info: notification 1", + "Info: notification 2", + "Warning: notification 3", + "Info: notification 4", + "Error: done notifying", + } + go func() { + count := 0 + for { + <-timer + if count == 5 { + app.Printf("This message is more important, printing it below the prompt and in every menu") + return + } + menu.TransientPrintf("%s", messages[count]) + count++ + } + }() + }, + } + root.AddCommand(ticker) + + main := &cobra.Command{ + Use: "main", + Short: "A command to return to the main menu (you can also use CtrlD for the same result)", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Switching back to main menu") + app.SwitchMenu("") + }, + } + root.AddCommand(main) + + shell := &cobra.Command{ + Use: "!", + Short: "Execute the remaining arguments with system shell", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("command requires one or more arguments") + } + + path, err := exec.LookPath(args[0]) + if err != nil { + return err + } + + shellCmd := exec.Command(path, args[1:]...) + + // Load OS environment + shellCmd.Env = os.Environ() + + out, err := shellCmd.CombinedOutput() + if err != nil { + return err + } + + fmt.Print(string(out)) + + return nil + }, + } + root.AddCommand(shell) + + interruptible := &cobra.Command{ + Use: "interrupt", + Short: "A command which prints a few status messages, but can be interrupted with CtrlC", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + menu := app.ActiveMenu() + timer := time.Tick(2 * time.Second) + messages := []string{ + "Info: notification 1", + "Info: notification 2", + "Warning: notification 3", + "Info: notification 4", + "Error: done notifying", + } + count := 0 + for { + select { + case <-menu.Context().Done(): + menu.TransientPrintf("Interrupted") + return nil + case <-timer: + if count == 5 { + return nil + } + menu.TransientPrintf("%s", messages[count]+"\n") + count++ + } + } + }, + } + root.AddCommand(interruptible) + + return root + } +} + +// setupPrompt is a function which sets up the prompts for the main menu. +func setupPrompt(m *console.Menu) { + p := m.Prompt() + + p.Primary = func() string { + prompt := "\x1b[33mexample\x1b[0m [main] in \x1b[34m%s\x1b[0m\n> " + wd, _ := os.Getwd() + + dir, err := filepath.Rel(os.Getenv("HOME"), wd) + if err != nil { + dir = filepath.Base(wd) + } + + return fmt.Sprintf(prompt, dir) + } + + p.Right = func() string { + return "\x1b[1;30m" + time.Now().Format("03:04:05.000") + "\x1b[0m" + } + + p.Transient = func() string { return "\x1b[1;30m" + ">> " + "\x1b[0m" } +} diff --git a/console/example/prompt.omp.json b/console/example/prompt.omp.json new file mode 100644 index 0000000..6385741 --- /dev/null +++ b/console/example/prompt.omp.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json", + "blocks": [ + { + "alignment": "left", + "newline": true, + "segments": [ + { + "foreground": "lightRed", + "style": "plain", + "template": " {{ .UserName }}@{{ .HostName }} ", + "type": "session" + }, + { + "foreground": "cyan", + "properties": { + "style": "folder" + }, + "style": "plain", + "template": "<#ffffff>in {{ .Path }} ", + "type": "path" + }, + { + "style": "plain", + "template": " {{ .Type }}(<#df2e1c>{{ .Path }}) ", + "type": "module" + } + ], + "type": "prompt" + }, + { + "alignment": "right", + "segments": [ + { + "foreground": "#ff94df", + "properties": { + "branch_icon": " <#ff94df>\ue0a0 ", + "fetch_stash_count": true + }, + "style": "plain", + "template": "<#ffffff>on {{ .HEAD }}{{ if gt .StashCount 0 }} \uf692 {{ .StashCount }}{{ end }} ", + "type": "git" + } + ], + "type": "rprompt" + }, + { + "alignment": "left", + "newline": true, + "segments": [ + { + "foreground": "lightGreen", + "style": "plain", + "template": "\u276f", + "type": "text" + } + ], + "type": "prompt" + } + ], + "final_space": true, + "version": 2 +} diff --git a/console/features_test.go b/console/features_test.go new file mode 100644 index 0000000..2eb8a61 --- /dev/null +++ b/console/features_test.go @@ -0,0 +1,124 @@ +package console + +import ( + "errors" + "fmt" + "io" + "testing" +) + +func TestHandleInterruptMatching(t *testing.T) { + c := New("test") + m := c.ActiveMenu() + + var fired []string + sentinel := errors.New("boom") + + m.AddInterrupt(sentinel, func(*Console) { fired = append(fired, "sentinel") }) + m.AddInterrupt(io.EOF, func(*Console) { fired = append(fired, "eof") }) + + // errors.Is match: a wrapped io.EOF should reach the io.EOF handler. + fired = nil + m.handleInterrupt(fmt.Errorf("read failed: %w", io.EOF)) + if !reflect_equal(fired, []string{"eof"}) { + t.Fatalf("wrapped io.EOF fired %v, want [eof]", fired) + } + + // String fallback: a distinct error value with the same message as the + // registered sentinel should still match (the historical pattern). + fired = nil + m.handleInterrupt(errors.New("boom")) + if !reflect_equal(fired, []string{"sentinel"}) { + t.Fatalf("same-message error fired %v, want [sentinel]", fired) + } + + // No match: nothing fires. + fired = nil + m.handleInterrupt(errors.New("unrelated")) + if len(fired) != 0 { + t.Fatalf("unrelated error fired %v, want none", fired) + } +} + +func TestMenuNewlineOverrides(t *testing.T) { + c := New("test") + c.NewlineAfter = true + c.NewlineBefore = false + c.NewlineWhenEmpty = false + m := c.ActiveMenu() + + // With no override, the menu inherits the console defaults. + if !m.newlineAfter() { + t.Fatal("newlineAfter: expected inherited true") + } + if m.newlineBefore() { + t.Fatal("newlineBefore: expected inherited false") + } + if m.newlineWhenEmpty() { + t.Fatal("newlineWhenEmpty: expected inherited false") + } + + // Overrides take precedence over the console default. + m.SetNewlineAfter(false) + m.SetNewlineBefore(true) + m.SetNewlineWhenEmpty(true) + + if m.newlineAfter() { + t.Fatal("newlineAfter: expected override false") + } + if !m.newlineBefore() { + t.Fatal("newlineBefore: expected override true") + } + if !m.newlineWhenEmpty() { + t.Fatal("newlineWhenEmpty: expected override true") + } + + // Changing the console default no longer affects an overridden menu. + c.NewlineAfter = true + if m.newlineAfter() { + t.Fatal("newlineAfter: override should shadow console default") + } +} + +func TestMenuEmptyCharsOverride(t *testing.T) { + c := New("test") + m := c.ActiveMenu() + + // Inherits the console default set. + if string(m.emptyCharSet()) != string(c.EmptyChars) { + t.Fatalf("emptyCharSet inherited = %q, want %q", string(m.emptyCharSet()), string(c.EmptyChars)) + } + + // Override. + m.SetEmptyChars('x', 'y') + if string(m.emptyCharSet()) != "xy" { + t.Fatalf("emptyCharSet override = %q, want %q", string(m.emptyCharSet()), "xy") + } + + // No arguments clears the override, restoring inheritance. + m.SetEmptyChars() + if string(m.emptyCharSet()) != string(c.EmptyChars) { + t.Fatalf("emptyCharSet after clear = %q, want %q", string(m.emptyCharSet()), string(c.EmptyChars)) + } +} + +func TestConsoleDefaultSignals(t *testing.T) { + c := New("test") + if len(c.Signals) != len(defaultTrapSignals) { + t.Fatalf("default Signals = %v, want %v", c.Signals, defaultTrapSignals) + } +} + +// reflect_equal is a tiny string-slice comparison helper to avoid importing +// reflect for a single use. +func reflect_equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/console/filters_test.go b/console/filters_test.go new file mode 100644 index 0000000..2155bed --- /dev/null +++ b/console/filters_test.go @@ -0,0 +1,84 @@ +package console + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +// buildFilterTree returns a small command tree: +// +// root +// └── net (filter: "windows") +// └── scan (no annotations -> inherits from parent) +// +// plus a standalone, unannotated "free" command. +func buildFilterTree() (net, scan, free *cobra.Command) { + root := &cobra.Command{Use: "root"} + net = &cobra.Command{Use: "net", Annotations: map[string]string{CommandFilterKey: "windows"}} + scan = &cobra.Command{Use: "scan"} + free = &cobra.Command{Use: "free"} + + root.AddCommand(net, free) + net.AddCommand(scan) + + return net, scan, free +} + +func TestActiveFiltersFor(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + net, scan, free := buildFilterTree() + + // No filter active yet: nothing is filtered, even annotated commands. + if got := menu.ActiveFiltersFor(net); len(got) != 0 { + t.Fatalf("before HideCommands: ActiveFiltersFor(net) = %q, want none", got) + } + + // Activate the "windows" filter. + c.HideCommands("windows") + + if got := menu.ActiveFiltersFor(net); !reflect.DeepEqual(got, []string{"windows"}) { + t.Fatalf("ActiveFiltersFor(net) = %q, want [windows]", got) + } + + // A child with no annotations inherits its parent's active filters. + if got := menu.ActiveFiltersFor(scan); !reflect.DeepEqual(got, []string{"windows"}) { + t.Fatalf("ActiveFiltersFor(scan) = %q, want [windows] (inherited)", got) + } + + // An unrelated, unannotated command is never filtered. + if got := menu.ActiveFiltersFor(free); len(got) != 0 { + t.Fatalf("ActiveFiltersFor(free) = %q, want none", got) + } + + // Removing the filter restores availability. + c.ShowCommands("windows") + if got := menu.ActiveFiltersFor(net); len(got) != 0 { + t.Fatalf("after ShowCommands: ActiveFiltersFor(net) = %q, want none", got) + } +} + +func TestCheckIsAvailable(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + net, scan, free := buildFilterTree() + + // A nil command is always available. + if err := menu.CheckIsAvailable(nil); err != nil { + t.Fatalf("CheckIsAvailable(nil) = %v, want nil", err) + } + + c.HideCommands("windows") + + if err := menu.CheckIsAvailable(net); err == nil { + t.Fatal("CheckIsAvailable(net) = nil, want error (command is filtered)") + } + if err := menu.CheckIsAvailable(scan); err == nil { + t.Fatal("CheckIsAvailable(scan) = nil, want error (inherited filter)") + } + if err := menu.CheckIsAvailable(free); err != nil { + t.Fatalf("CheckIsAvailable(free) = %v, want nil (not filtered)", err) + } +} diff --git a/console/fork_test.go b/console/fork_test.go new file mode 100644 index 0000000..35b74e8 --- /dev/null +++ b/console/fork_test.go @@ -0,0 +1,104 @@ +package console + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/chainreactors/tui/readline" + rlterm "github.com/chainreactors/tui/readline/terminal" +) + +func TestNewWithTerminalBindsShellAndConsoleOutput(t *testing.T) { + var out bytes.Buffer + term := rlterm.Stream(strings.NewReader(""), &out, &out, rlterm.NewControl(false, 80, 24)) + c := NewWithTerminal("testapp", term) + + if c.terminal != term { + t.Fatal("console did not retain caller-provided terminal") + } + if c.Shell().Terminal != term { + t.Fatal("readline shell did not retain caller-provided terminal") + } + + c.isExecuting.Store(true) + defer c.isExecuting.Store(false) + + if _, err := c.Printf("log %s", "message"); err != nil { + t.Fatalf("Printf returned error: %v", err) + } + if got := out.String(); got != "log message" { + t.Fatalf("terminal output = %q, want %q", got, "log message") + } +} + +func TestDisplayNewlinesUseTerminalOutput(t *testing.T) { + var out bytes.Buffer + term := rlterm.Stream(strings.NewReader(""), &out, &out, rlterm.NewControl(false, 80, 24)) + c := NewWithTerminal("testapp", term) + + c.NewlineBefore = true + c.displayPreRun("cmd") + if got := out.String(); got != "\n" { + t.Fatalf("displayPreRun wrote %q, want newline", got) + } + + out.Reset() + c.NewlineAfter = true + c.displayPostRun("cmd") + if got := out.String(); got != "\n" { + t.Fatalf("displayPostRun wrote %q, want newline", got) + } +} + +func TestExecuteExportedRunsCommands(t *testing.T) { + c := NewWithTerminal("testapp", rlterm.Stream(strings.NewReader(""), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 80, 24))) + menu := c.ActiveMenu() + + ran := false + menu.SetCommands(func() *cobra.Command { + root := &cobra.Command{Use: "root"} + root.AddCommand(&cobra.Command{ + Use: "run", + Run: func(*cobra.Command, []string) { + ran = true + }, + }) + return root + }) + menu.resetPreRun() + + if err := c.Execute(context.Background(), menu, []string{"run"}, false); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if !ran { + t.Fatal("Execute did not run target command") + } + if c.isExecuting.Load() { + t.Fatal("Execute left console in executing state") + } +} + +func TestCompletionInlineSuggestionBridge(t *testing.T) { + c := NewWithTerminal("testapp", rlterm.Stream(strings.NewReader(""), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 80, 24))) + + comps := readline.CompleteRaw([]readline.Completion{{Value: "status "}}) + comps.PREFIX = "sta" + c.setInlineSuggestion([]rune("sta"), 3, comps) + + if got := c.Shell().GetInlineSuggestion(); got != "status" { + t.Fatalf("inline suggestion = %q, want %q", got, "status") + } + + c.setInlineSuggestion([]rune("sta"), 1, comps) + if got := c.Shell().GetInlineSuggestion(); got != "" { + t.Fatalf("inline suggestion after mid-line cursor = %q, want empty", got) + } +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/console/go.mod b/console/go.mod index 785e472..8f9ec56 100644 --- a/console/go.mod +++ b/console/go.mod @@ -1,23 +1,23 @@ module github.com/chainreactors/tui/console -go 1.21 +go 1.25.0 require ( - github.com/carapace-sh/carapace v1.7.1 - github.com/chainreactors/tui/readline v1.1.3 + github.com/carapace-sh/carapace v1.11.6 + github.com/chainreactors/tui/readline v1.2.2 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.9 - golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 - mvdan.cc/sh/v3 v3.7.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 + mvdan.cc/sh/v3 v3.13.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect - github.com/carapace-sh/carapace-shlex v1.0.1 // indirect + github.com/carapace-sh/carapace-shlex v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.8.0 // indirect + golang.org/x/sys v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/console/go.sum b/console/go.sum index 605b708..87dd307 100644 --- a/console/go.sum +++ b/console/go.sum @@ -1,15 +1,18 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/carapace-sh/carapace v1.7.1 h1:GjMjPNEMHhTstneZD2M3Ypjb+lW5YNEV1AfYmRhsG4c= -github.com/carapace-sh/carapace v1.7.1/go.mod h1:fHdo3nEFe1QnIXxeA/Z1O9dCI83sfCsKfxrogpHfgtM= -github.com/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c= -github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/carapace-sh/carapace v1.11.6 h1:fUZv+oAMgbiDEpNPNis4n35tzqE3h8yshOohLJ2Mz4Y= +github.com/carapace-sh/carapace v1.11.6/go.mod h1:5MUSHyLN9GGb5/NY/j9VI68/TcZV4ApRCAHGg4WeU0s= +github.com/carapace-sh/carapace-shlex v1.1.1 h1:ccmNeetAYZOk4IcV36youFDsXusT9uCNW2Njkw+QS+Q= +github.com/carapace-sh/carapace-shlex v1.1.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= -github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 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= @@ -23,24 +26,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= -github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= -mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/console/highlight_cache_test.go b/console/highlight_cache_test.go new file mode 100644 index 0000000..6e07393 --- /dev/null +++ b/console/highlight_cache_test.go @@ -0,0 +1,40 @@ +package console + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestHighlightCacheInvalidation(t *testing.T) { + c := New("test") + menu := c.ActiveMenu() + menu.SetCommands(func() *cobra.Command { + root := &cobra.Command{Use: "root"} + root.AddCommand(&cobra.Command{Use: "net", Run: func(*cobra.Command, []string) {}}) + return root + }) + menu.resetPreRun() + + in := []rune("net") + first := c.highlightSyntax(in) + + cached := c.hlCache.Load() + if cached == nil || cached.input != "net" { + t.Fatalf("expected cache populated for %q, got %+v", "net", cached) + } + if cached.output != first { + t.Fatalf("cached output %q != returned %q", cached.output, first) + } + + // Same input is served from cache and yields the same result. + if second := c.highlightSyntax(in); second != first { + t.Fatalf("second highlight %q != first %q", second, first) + } + + // Regenerating the command tree invalidates the cache. + menu.resetPreRun() + if c.hlCache.Load() != nil { + t.Fatal("expected highlight cache cleared after resetPreRun") + } +} diff --git a/console/highlighter.go b/console/highlighter.go deleted file mode 100644 index 48f53a6..0000000 --- a/console/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/console/internal/completion/complete.go b/console/internal/completion/complete.go new file mode 100644 index 0000000..7c27144 --- /dev/null +++ b/console/internal/completion/complete.go @@ -0,0 +1,32 @@ +package completion + +import ( + "fmt" + "os" + + "github.com/carapace-sh/carapace/pkg/style" + "github.com/carapace-sh/carapace/pkg/xdg" +) + +// 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") +} diff --git a/console/internal/completion/line.go b/console/internal/completion/line.go new file mode 100644 index 0000000..25d22f0 --- /dev/null +++ b/console/internal/completion/line.go @@ -0,0 +1,255 @@ +package completion + +import ( + "bytes" + "errors" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/chainreactors/tui/console/internal/line" +) + +// 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/console/internal/completion/line_test.go b/console/internal/completion/line_test.go new file mode 100644 index 0000000..d3168dd --- /dev/null +++ b/console/internal/completion/line_test.go @@ -0,0 +1,109 @@ +package completion + +import ( + "reflect" + "testing" + + "github.com/chainreactors/tui/console/internal/line" +) + +func TestSplitCompWords(t *testing.T) { + tests := []struct { + name string + input string + wantWords []string + wantRemainder string + wantErr error + }{ + {"empty", "", []string{}, "", nil}, + {"two words", "echo hello", []string{"echo", "hello"}, "", nil}, + {"single quoted", "echo 'hello world'", []string{"echo", "hello world"}, "", nil}, + {"double quoted", `echo "hello world"`, []string{"echo", "hello world"}, "", nil}, + {"unterminated single", "echo 'foo", []string{"echo"}, "foo", line.ErrUnterminatedSingleQuote}, + {"unterminated double", `echo "foo`, []string{"echo"}, "foo", line.ErrUnterminatedDoubleQuote}, + {"trailing backslash", `echo foo\`, []string{"echo"}, `foo\`, line.ErrUnterminatedEscape}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + words, remainder, err := splitCompWords(tc.input) + if err != tc.wantErr { + t.Fatalf("splitCompWords(%q) err = %v, want %v", tc.input, err, tc.wantErr) + } + if !reflect.DeepEqual(words, tc.wantWords) { + t.Fatalf("splitCompWords(%q) words = %q, want %q", tc.input, words, tc.wantWords) + } + if remainder != tc.wantRemainder { + t.Fatalf("splitCompWords(%q) remainder = %q, want %q", tc.input, remainder, tc.wantRemainder) + } + }) + } +} + +func TestAdjustQuotedPrefix(t *testing.T) { + tests := []struct { + name string + remain string + err error + wantArg string + wantComp string + wantInput string + }{ + {"no error", "foo", nil, "foo", "", ""}, + {"double quote", "foo", line.ErrUnterminatedDoubleQuote, "foo", `"`, `"foo`}, + {"single quote", "foo", line.ErrUnterminatedSingleQuote, "foo", "'", "'foo"}, + {"escape strips backslashes", `fo\o`, line.ErrUnterminatedEscape, "foo", "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + arg, comp, input := adjustQuotedPrefix(tc.remain, tc.err) + if arg != tc.wantArg || comp != tc.wantComp || input != tc.wantInput { + t.Fatalf("adjustQuotedPrefix(%q) = (%q, %q, %q), want (%q, %q, %q)", + tc.remain, arg, comp, input, tc.wantArg, tc.wantComp, tc.wantInput) + } + }) + } +} + +func TestSanitizeArgs(t *testing.T) { + got := sanitizeArgs([]string{"a\nb", "c\td", `e\ f`}) + want := []string{"a b", "c d", "e f"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("sanitizeArgs = %q, want %q", got, want) + } +} + +func TestSplitArgs(t *testing.T) { + tests := []struct { + name string + input string + wantArgs []string + wantPrefixComp string + wantPrefixLine string + }{ + {"empty line completes root", "", []string{""}, "", ""}, + {"partial word", "cmd", []string{"cmd"}, "", ""}, + {"trailing space starts new word", "cmd ", []string{"cmd", ""}, "", ""}, + {"two words", "cmd arg", []string{"cmd", "arg"}, "", ""}, + {"unterminated double quote", `cmd "foo`, []string{"cmd", "foo"}, `"`, `"foo`}, + {"unterminated single quote", "cmd 'foo", []string{"cmd", "foo"}, "'", "'foo"}, + {"color codes stripped", "\x1b[32mcmd\x1b[0m", []string{"cmd"}, "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runes := []rune(tc.input) + args, prefixComp, prefixLine := SplitArgs(runes, len(runes)) + if !reflect.DeepEqual(args, tc.wantArgs) { + t.Fatalf("SplitArgs(%q) args = %q, want %q", tc.input, args, tc.wantArgs) + } + if prefixComp != tc.wantPrefixComp { + t.Fatalf("SplitArgs(%q) prefixComp = %q, want %q", tc.input, prefixComp, tc.wantPrefixComp) + } + if prefixLine != tc.wantPrefixLine { + t.Fatalf("SplitArgs(%q) prefixLine = %q, want %q", tc.input, prefixLine, tc.wantPrefixLine) + } + }) + } +} diff --git a/console/internal/line/highlight.go b/console/internal/line/highlight.go new file mode 100644 index 0000000..41ad87e --- /dev/null +++ b/console/internal/line/highlight.go @@ -0,0 +1,78 @@ +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() { + // Highlight based on first arg in usage rather than the entire usage itself, + // or on any of the command's aliases. + name := strings.TrimSpace(args[0]) + cmdFound := strings.Split(cmd.Use, " ")[0] == name || slices.Contains(cmd.Aliases, name) + + 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/console/internal/line/highlight_test.go b/console/internal/line/highlight_test.go new file mode 100644 index 0000000..5aac0e4 --- /dev/null +++ b/console/internal/line/highlight_test.go @@ -0,0 +1,69 @@ +package line + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestHighlightCommandAlias is a regression test for a bug where a command +// invoked through one of its aliases was never highlighted: the alias branch +// used to `break` out of the loop before reaching the highlight block. +func TestHighlightCommandAlias(t *testing.T) { + root := &cobra.Command{Use: "app"} + root.AddCommand(&cobra.Command{Use: "deploy host", Aliases: []string{"d", "dep"}}) + + tests := []struct { + name string + arg string + want bool // whether arg should be highlighted as a command + }{ + {"canonical name", "deploy", true}, + {"first alias", "d", true}, + {"second alias", "dep", true}, + {"unknown word", "nope", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + done, _ := HighlightCommand(nil, []string{tc.arg}, root, GreenFG) + + highlighted := len(done) > 0 && strings.Contains(done[0], GreenFG) + if highlighted != tc.want { + t.Fatalf("arg %q: highlighted=%v, want %v (got %q)", tc.arg, highlighted, tc.want, done) + } + }) + } +} + +func TestHighlightCommandFlags(t *testing.T) { + args := []string{"--verbose", "target", "-x", "value"} + done, _ := HighlightCommandFlags(nil, args, BrightWhiteFG) + + if len(done) != len(args) { + t.Fatalf("HighlightCommandFlags returned %d words, want %d: %q", len(done), len(args), done) + } + + tests := []struct { + idx int + shouldHighlit bool + raw string + }{ + {0, true, "--verbose"}, + {1, false, "target"}, + {2, true, "-x"}, + {3, false, "value"}, + } + + for _, tc := range tests { + got := done[tc.idx] + colored := strings.Contains(got, BrightWhiteFG) + if colored != tc.shouldHighlit { + t.Errorf("word %q: highlighted=%v, want %v (got %q)", tc.raw, colored, tc.shouldHighlit, got) + } + if !strings.Contains(got, tc.raw) { + t.Errorf("word %d: %q does not contain original %q", tc.idx, got, tc.raw) + } + } +} diff --git a/console/line.go b/console/internal/line/line.go similarity index 63% rename from console/line.go rename to console/internal/line/line.go index fe7b19e..130c77b 100644 --- a/console/line.go +++ b/console/internal/line/line.go @@ -1,33 +1,32 @@ -package console +package line import ( "bytes" "errors" - "regexp" "strings" "unicode/utf8" + "github.com/kballard/go-shellquote" "mvdan.cc/sh/v3/syntax" ) 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)) @@ -45,41 +44,23 @@ func (c *Console) parse(line string) (args []string, err error) { } // Split the line into shell words. - return shellSplit(parsedLine.String()) - //return shellquote.Split(parsedLine.String()) -} - -func shellSplit(command string) (args []string, err error) { - re := regexp.MustCompile(`[^\s"']+|"([^"]*)"|'([^']*)'`) - matches := re.FindAllStringSubmatch(command, -1) - - var parts []string - for _, match := range matches { - if match[1] != "" { // Matched double-quoted part - parts = append(parts, match[1]) - } else if match[2] != "" { // Matched single-quoted part - parts = append(parts, match[2]) - } else { // Unquoted part - parts = append(parts, match[0]) - } - } - return parts, nil + return shellquote.Split(parsedLine.String()) } // 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 } @@ -90,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 { @@ -112,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 } @@ -166,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) @@ -203,7 +223,7 @@ escape: if hl { input = buf.String() + input } - return "", input, errUnterminatedEscape + return "", input, ErrUnterminatedEscape } c, l := utf8.DecodeRuneInString(input) if c == '\n' { @@ -218,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 } @@ -247,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) } @@ -258,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 @@ -279,33 +299,12 @@ 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/console/internal/line/line_test.go b/console/internal/line/line_test.go new file mode 100644 index 0000000..192e589 --- /dev/null +++ b/console/internal/line/line_test.go @@ -0,0 +1,156 @@ +package line + +import ( + "errors" + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {"empty", "", nil, false}, + {"simple", "echo hello", []string{"echo", "hello"}, false}, + {"extra spaces collapse", "echo hello world", []string{"echo", "hello", "world"}, false}, + {"trailing comment", "echo hello # a comment", []string{"echo", "hello"}, false}, + {"comment only", "# just a comment", nil, false}, + {"single quotes", "echo 'hello world'", []string{"echo", "hello world"}, false}, + {"double quotes", `echo "hello world"`, []string{"echo", "hello world"}, false}, + {"quoted hash not a comment", "echo '# not a comment'", []string{"echo", "# not a comment"}, false}, + {"unterminated single quote", "echo 'oops", nil, true}, + {"unterminated double quote", `echo "oops`, nil, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Parse(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("Parse(%q): expected error, got nil (words=%q)", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("Parse(%q): unexpected error: %v", tc.input, err) + } + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Parse(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSplit(t *testing.T) { + tests := []struct { + name string + input string + wantWords []string + wantErr error + }{ + {"empty", "", []string{}, nil}, + {"simple", "echo hello", []string{"echo", "hello"}, nil}, + {"single quotes", "echo 'hello world'", []string{"echo", "hello world"}, nil}, + {"double quotes", `echo "hello world"`, []string{"echo", "hello world"}, nil}, + {"escaped space", `echo foo\ bar`, []string{"echo", "foo bar"}, nil}, + {"unterminated single", "echo 'oops", []string{"echo"}, ErrUnterminatedSingleQuote}, + {"unterminated double", `echo "oops`, []string{"echo"}, ErrUnterminatedDoubleQuote}, + {"trailing backslash", `echo foo\`, []string{"echo"}, ErrUnterminatedEscape}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + words, _, err := Split(tc.input, false) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Split(%q) err = %v, want %v", tc.input, err, tc.wantErr) + } + if !reflect.DeepEqual(words, tc.wantWords) { + t.Fatalf("Split(%q) words = %q, want %q", tc.input, words, tc.wantWords) + } + }) + } +} + +func TestAcceptMultiline(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"complete", "echo hello", true}, + {"complete quoted", `echo "hello world"`, true}, + {"unterminated single quote", "echo 'oops", false}, + {"unterminated double quote", `echo "oops`, false}, + {"trailing backslash", `echo foo\`, false}, + {"empty", "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := AcceptMultiline([]rune(tc.input)); got != tc.want { + t.Fatalf("AcceptMultiline(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestIsEmpty(t *testing.T) { + empty := []rune{' ', '\t'} + + tests := []struct { + name string + input string + chars []rune + want bool + }{ + {"empty string", "", empty, true}, + {"only spaces", " ", empty, true}, + {"spaces and tabs", " \t \t ", empty, true}, + {"has content", " x ", empty, false}, + {"content no chars", "abc", nil, false}, + {"newline not in set", "\n", empty, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsEmpty(tc.input, tc.chars...); got != tc.want { + t.Fatalf("IsEmpty(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestTrimSpaces(t *testing.T) { + got := TrimSpaces([]string{" a ", "b\t", "\tc"}) + want := []string{"a", "b", "c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("TrimSpaces = %q, want %q", got, want) + } +} + +func TestUnescapeValue(t *testing.T) { + tests := []struct { + name string + prefixLine string + val string + want string + }{ + {"double-quoted unescapes spaces", `"foo`, `bar\ baz`, "bar baz"}, + {"single-quoted unescapes spaces", `'foo`, `bar\ baz`, "bar baz"}, + {"unquoted left as-is", "foo", `bar\ baz`, `bar\ baz`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := UnescapeValue("", tc.prefixLine, tc.val); got != tc.want { + t.Fatalf("UnescapeValue(%q, %q) = %q, want %q", tc.prefixLine, tc.val, got, tc.want) + } + }) + } +} diff --git a/console/internal/strutil/template.go b/console/internal/strutil/template.go new file mode 100644 index 0000000..59b96c2 --- /dev/null +++ b/console/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/console/internal/strutil/template_test.go b/console/internal/strutil/template_test.go new file mode 100644 index 0000000..3bfd5d9 --- /dev/null +++ b/console/internal/strutil/template_test.go @@ -0,0 +1,52 @@ +package strutil + +import ( + "strings" + "testing" +) + +func TestTemplate(t *testing.T) { + tests := []struct { + name string + text string + data any + want string + }{ + { + name: "simple field", + text: "Hello {{.Name}}", + data: map[string]any{"Name": "world"}, + want: "Hello world", + }, + { + name: "trim func", + text: "[{{trim .S}}]", + data: map[string]any{"S": " padded "}, + want: "[padded]", + }, + { + name: "range over slice", + text: "{{range .Items}}{{.}},{{end}}", + data: map[string]any{"Items": []string{"a", "b", "c"}}, + want: "a,b,c,", + }, + { + name: "no substitution", + text: "static text", + data: nil, + want: "static text", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var b strings.Builder + if err := Template(&b, tc.text, tc.data); err != nil { + t.Fatalf("Template(%q): unexpected error: %v", tc.text, err) + } + if got := b.String(); got != tc.want { + t.Fatalf("Template(%q) = %q, want %q", tc.text, got, tc.want) + } + }) + } +} diff --git a/console/prompt.go b/console/internal/ui/prompt.go similarity index 65% rename from console/prompt.go rename to console/internal/ui/prompt.go index e44d678..515e5f2 100644 --- a/console/prompt.go +++ b/console/internal/ui/prompt.go @@ -1,6 +1,7 @@ -package console +package ui import ( + "bytes" "fmt" "strings" @@ -16,27 +17,25 @@ 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 - - menu := app.activeMenu() + promptStr := appName - 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,21 +45,18 @@ 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 - // must leave a newline after command/log output, wrap its function - // to add a newline before the prompt. + // Guard against a nil primary prompt, since the shell calls this on + // every render. Newlines around the prompt are handled by readline. primary := func() string { if p.Primary == nil { return "" } - prompt := p.Primary() - - return prompt + return p.Primary() } prompt.Primary(primary) diff --git a/console/interrupt.go b/console/interrupt.go index a70ba33..c0f952c 100644 --- a/console/interrupt.go +++ b/console/interrupt.go @@ -1,23 +1,30 @@ package console +import "errors" + // AddInterrupt registers a handler to run when the console receives // a given interrupt error from the underlying readline shell. // // On most systems, the following errors will be returned with keypresses: // - Linux/MacOS/Windows : Ctrl-C will return os.Interrupt. // +// The incoming error is matched against the registered one with errors.Is +// first (so wrapped errors and sentinel values work as expected), falling +// back to comparing their messages for errors that are merely value-equal +// (e.g. two distinct errors.New with the same text). +// // Many will want to use this to switch menus. Note that these interrupt errors only // work when the console is NOT currently executing a command, only when reading input. func (m *Menu) AddInterrupt(err error, handler func(c *Console)) { - m.mutex.RLock() + m.mutex.Lock() m.interruptHandlers[err] = handler - m.mutex.RUnlock() + m.mutex.Unlock() } // DelInterrupt removes one or more interrupt handlers from the menu registered ones. // If no error is passed as argument, all handlers are removed. func (m *Menu) DelInterrupt(errs ...error) { - m.mutex.RLock() + m.mutex.Lock() if len(errs) == 0 { m.interruptHandlers = make(map[error]func(c *Console)) } else { @@ -25,30 +32,30 @@ func (m *Menu) DelInterrupt(errs ...error) { delete(m.interruptHandlers, err) } } - m.mutex.RUnlock() + m.mutex.Unlock() } func (m *Menu) handleInterrupt(err error) { - m.console.mutex.RLock() - m.console.isExecuting = true - m.console.mutex.RUnlock() + m.console.isExecuting.Store(true) + defer m.console.isExecuting.Store(false) - defer func() { - m.console.mutex.RLock() - m.console.isExecuting = false - m.console.mutex.RUnlock() - }() - - // TODO: this is not a very, very safe way of comparing - // errors. I'm not sure what to right now with this, but - // from my (unreliable) expectations and usage, I see and - // use things like errors.New(os.Interrupt.String()), so - // the string itself is likely to change in the future. + // Match with errors.Is first so sentinel and wrapped errors behave + // correctly, then fall back to comparing messages for errors that are + // only value-equal (the historically supported errors.New(...) pattern). // - // But if people use their own third-party errors... nothing is guaranteed. + // Snapshot the matching handlers under the lock, then run them once + // released: a handler is free to mutate the menu (e.g. SwitchMenu) + // without deadlocking, and the map can't be written mid-iteration. + m.mutex.RLock() + matched := make([]func(c *Console), 0, len(m.interruptHandlers)) for herr, handler := range m.interruptHandlers { - if err.Error() == herr.Error() { - handler(m.console) + if errors.Is(err, herr) || err.Error() == herr.Error() { + matched = append(matched, handler) } } + m.mutex.RUnlock() + + for _, handler := range matched { + handler(m.console) + } } diff --git a/console/menu.go b/console/menu.go index 2befb3c..7812f92 100644 --- a/console/menu.go +++ b/console/menu.go @@ -4,16 +4,21 @@ import ( "bytes" "errors" "fmt" - "io" "strings" "sync" - "text/template" "github.com/spf13/cobra" + "github.com/chainreactors/tui/console/internal/strutil" + "github.com/chainreactors/tui/console/internal/ui" "github.com/chainreactors/tui/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. @@ -51,6 +56,13 @@ type Menu struct { historyNames []string histories map[string]readline.History + // Per-menu overrides of the console newline behavior. When a *bool is nil + // (or emptyChars is nil), the corresponding Console default is used. + nlBefore *bool + nlAfter *bool + nlWhenEmpty *bool + emptyChars []rune + // Concurrency management mutex *sync.RWMutex } @@ -59,18 +71,18 @@ 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)), histories: make(map[string]readline.History), mutex: &sync.RWMutex{}, - ErrorHandler: func(err error) error { - fmt.Fprintf(console.terminal.Err, "Error: %s\n", err) - return nil - }, + 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()`. @@ -93,11 +105,84 @@ func (m *Menu) Prompt() *Prompt { return m.prompt } +// SetNewlineBefore overrides Console.NewlineBefore for this menu only. +func (m *Menu) SetNewlineBefore(v bool) { + m.mutex.Lock() + m.nlBefore = &v + m.mutex.Unlock() +} + +// SetNewlineAfter overrides Console.NewlineAfter for this menu only. +func (m *Menu) SetNewlineAfter(v bool) { + m.mutex.Lock() + m.nlAfter = &v + m.mutex.Unlock() +} + +// SetNewlineWhenEmpty overrides Console.NewlineWhenEmpty for this menu only. +func (m *Menu) SetNewlineWhenEmpty(v bool) { + m.mutex.Lock() + m.nlWhenEmpty = &v + m.mutex.Unlock() +} + +// SetEmptyChars overrides Console.EmptyChars for this menu only. Passing no +// arguments clears the override, restoring the console default. +func (m *Menu) SetEmptyChars(chars ...rune) { + m.mutex.Lock() + m.emptyChars = chars + m.mutex.Unlock() +} + +func (m *Menu) newlineBefore() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.nlBefore != nil { + return *m.nlBefore + } + + return m.console.NewlineBefore +} + +func (m *Menu) newlineAfter() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.nlAfter != nil { + return *m.nlAfter + } + + return m.console.NewlineAfter +} + +func (m *Menu) newlineWhenEmpty() bool { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.nlWhenEmpty != nil { + return *m.nlWhenEmpty + } + + return m.console.NewlineWhenEmpty +} + +func (m *Menu) emptyCharSet() []rune { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if m.emptyChars != nil { + return m.emptyChars + } + + return m.console.EmptyChars +} + // AddHistorySource adds a source of history commands that will // be accessible to the shell when the menu is active. func (m *Menu) AddHistorySource(name string, source readline.History) { - m.mutex.RLock() - defer m.mutex.RUnlock() + m.mutex.Lock() + defer m.mutex.Unlock() if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() { delete(m.histories, m.defaultHistoryName()) @@ -112,8 +197,8 @@ func (m *Menu) AddHistorySource(name string, source readline.History) { // to the specified "filepath" parameter. On the first call to this function, // the default in-memory history source is removed. func (m *Menu) AddHistorySourceFile(name string, filepath string) { - m.mutex.RLock() - defer m.mutex.RUnlock() + m.mutex.Lock() + defer m.mutex.Unlock() if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() { delete(m.histories, m.defaultHistoryName()) @@ -212,16 +297,16 @@ func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error { return nil } - m.console.mutex.Lock() - defer m.console.mutex.Unlock() filters := m.ActiveFiltersFor(cmd) if len(filters) == 0 { 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, @@ -236,9 +321,22 @@ func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error { // ActiveFiltersFor returns all the active menu filters that a given command // does not declare as compliant with (added with console.Hide/ShowCommand()). func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string { + // Snapshot the console filters once under a read lock, then walk the + // command tree lock-free. The previous version held a write lock and + // recursed into itself while holding it, which both serialized every + // completion/highlight render and risked a self-deadlock on the + // (non-reentrant) mutex whenever the parent-subtree branch was taken. + m.console.mutex.RLock() + consoleFilters := append([]string(nil), m.console.filters...) + m.console.mutex.RUnlock() + + return activeFiltersFor(cmd, consoleFilters) +} + +func activeFiltersFor(cmd *cobra.Command, consoleFilters []string) []string { if cmd.Annotations == nil { if cmd.HasParent() { - return m.ActiveFiltersFor(cmd.Parent()) + return activeFiltersFor(cmd.Parent(), consoleFilters) } return nil @@ -249,7 +347,7 @@ func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string { var filters []string for _, cmdFilter := range strings.Split(filterStr, ",") { - for _, filter := range m.console.filters { + for _, filter := range consoleFilters { if cmdFilter != "" && cmdFilter == filter { filters = append(filters, cmdFilter) } @@ -261,7 +359,7 @@ func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string { } // Any parent that is hidden make its whole subtree hidden also. - return m.ActiveFiltersFor(cmd.Parent()) + return activeFiltersFor(cmd.Parent(), consoleFilters) } // SetErrFilteredCommandTemplate sets the error template to be used @@ -278,6 +376,30 @@ func (m *Menu) resetPreRun() { defer m.mutex.Unlock() // Commands + m.regenerate() + + // Reset or adjust any buffered command output. + m.resetCmdOutput() + + // Prompt binding + prompt := (*ui.Prompt)(m.Prompt()) + ui.BindPrompt(prompt, m.console.shell) +} + +// resetCommands regenerates the menu command tree and re-applies filtering, +// without rebinding the prompt or touching the command-output buffer. It is the +// lighter reset used on the completion hot path, where the prompt is already +// bound and no command output was produced. +func (m *Menu) resetCommands() { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.regenerate() +} + +// regenerate rebuilds the command tree and hides filtered commands. +// It assumes m.mutex is already held. +func (m *Menu) regenerate() { if m.cmds != nil { m.Command = m.cmds() } @@ -288,24 +410,17 @@ func (m *Menu) resetPreRun() { } } - // Hide commands that are not available + // 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 + // The command tree just changed, so any memoized highlight is now stale. + m.console.hlCache.Store(nil) } // hide commands that are filtered so that they are not // shown in the help strings or proposed as completions. func (m *Menu) hideFilteredCommands(root *cobra.Command) { for _, cmd := range root.Commands() { - // Always hide carapace internal helper commands. - if cmd.Name() == "_carapace" { - cmd.Hidden = true - continue - } - // Don't override commands if they are already hidden if cmd.Hidden { continue @@ -314,9 +429,6 @@ func (m *Menu) hideFilteredCommands(root *cobra.Command) { if filters := m.ActiveFiltersFor(cmd); len(filters) > 0 { cmd.Hidden = true } - - // Recurse into subcommands to hide nested _carapace commands. - m.hideFilteredCommands(cmd) } } @@ -324,7 +436,7 @@ func (m *Menu) resetCmdOutput() { buf := strings.TrimSpace(m.out.String()) // If our command has printed everything to stdout, nothing to do. - if len(buf) == 0 || buf == "" { + if len(buf) == 0 { m.out.Reset() return } @@ -352,16 +464,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/console/newline_display_test.go b/console/newline_display_test.go new file mode 100644 index 0000000..10c9070 --- /dev/null +++ b/console/newline_display_test.go @@ -0,0 +1,108 @@ +package console + +import ( + "io" + "os" + "testing" +) + +// captureStdout redirects os.Stdout for the duration of fn and returns what was +// written. The display functions print via fmt.Println, which targets +// os.Stdout directly. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + os.Stdout = w + fn() + os.Stdout = orig + + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read: %v", err) + } + _ = r.Close() + + return string(out) +} + +func TestDisplayNewlineMatrix(t *testing.T) { + // A newline is printed iff: enabled && (whenEmpty || input is non-empty). + cases := []struct { + name string + enabled bool + whenEmpty bool + input string + wantNewline bool + }{ + {"disabled/empty", false, false, "", false}, + {"disabled/nonempty", false, false, "cmd", false}, + {"disabled/whenEmpty/nonempty", false, true, "cmd", false}, + + {"enabled/nonempty", true, false, "cmd", true}, + {"enabled/empty", true, false, "", false}, + {"enabled/spaces-are-empty", true, false, " \t ", false}, + + {"enabled/whenEmpty/empty", true, true, "", true}, + {"enabled/whenEmpty/nonempty", true, true, "cmd", true}, + {"enabled/whenEmpty/spaces", true, true, " \t ", true}, + } + + for _, tc := range cases { + want := "" + if tc.wantNewline { + want = "\n" + } + + t.Run("pre/"+tc.name, func(t *testing.T) { + c := New("test") + c.NewlineBefore = tc.enabled + c.NewlineWhenEmpty = tc.whenEmpty + + got := captureStdout(t, func() { c.displayPreRun(tc.input) }) + if got != want { + t.Fatalf("displayPreRun(%q) printed %q, want %q", tc.input, got, want) + } + }) + + t.Run("post/"+tc.name, func(t *testing.T) { + c := New("test") + c.NewlineAfter = tc.enabled + c.NewlineWhenEmpty = tc.whenEmpty + + got := captureStdout(t, func() { c.displayPostRun(tc.input) }) + if got != want { + t.Fatalf("displayPostRun(%q) printed %q, want %q", tc.input, got, want) + } + }) + } +} + +// TestDisplayNewlineMenuOverride checks that a per-menu newline override is +// honored by the display path even when the console default differs. +func TestDisplayNewlineMenuOverride(t *testing.T) { + c := New("test") + c.NewlineAfter = false // console default: off + c.ActiveMenu().SetNewlineAfter(true) + + if got := captureStdout(t, func() { c.displayPostRun("cmd") }); got != "\n" { + t.Fatalf("menu override on: displayPostRun printed %q, want %q", got, "\n") + } + + // And the inverse: console on, menu override off. + c.NewlineAfter = true + c.ActiveMenu().SetNewlineAfter(false) + + if got := captureStdout(t, func() { c.displayPostRun("cmd") }); got != "" { + t.Fatalf("menu override off: displayPostRun printed %q, want %q", got, "") + } +} diff --git a/console/run.go b/console/run.go index 3ee3524..d78ddd5 100644 --- a/console/run.go +++ b/console/run.go @@ -10,6 +10,8 @@ import ( "github.com/kballard/go-shellquote" "github.com/spf13/cobra" + + "github.com/chainreactors/tui/console/internal/line" ) // Start - Start the console application (readline loop). Blocking. @@ -19,7 +21,20 @@ func (c *Console) Start() error { return c.StartContext(context.Background()) } -// StartContext is like console.Start(). with a user-provided context. +// StartContext is like console.Start(), with a user-provided context. +// +// Cancellation model: each command runs with a context derived from ctx, +// accessible from within the command via cmd.Context(). When the console +// traps one of its Signals (SIGINT/SIGTERM/SIGQUIT by default) while a command +// is running, that command's context is cancelled and any registered interrupt +// handler for the menu is invoked. Cancelling ctx itself does the same on the +// next command boundary. +// +// Because cobra cannot preempt a running command, a long-running command is +// only actually interrupted if it observes cancellation itself: select on +// cmd.Context().Done() (or pass cmd.Context() to context-aware callees) and +// return promptly. A command that ignores its context keeps running in its +// goroutine until it finishes, even though the prompt has already been freed. func (c *Console) StartContext(ctx context.Context) error { c.loadActiveHistories() @@ -31,6 +46,8 @@ func (c *Console) StartContext(ctx context.Context) error { lastLine := "" // used to check if last read line is empty. for { + // Print a newline after the last output if NewlineAfter is true + // and the last line was not empty. c.displayPostRun(lastLine) // Always ensure we work with the active menu, with freshly @@ -45,15 +62,10 @@ func (c *Console) StartContext(ctx context.Context) error { } // Block and read user input. - line, err := c.Readline() - - c.displayPostRun(line) - + input, err := c.Readline() if err != nil { menu.handleInterrupt(err) - - lastLine = line - + lastLine = input continue } @@ -63,15 +75,15 @@ func (c *Console) StartContext(ctx context.Context) error { menu = c.activeMenu() // Parse the line with bash-syntax, removing comments. - resolvedLine := c.ResolvePasteReferences(line) - args, err := c.parse(resolvedLine) + resolvedInput := c.ResolvePasteReferences(input) + args, err := line.Parse(resolvedInput) if err != nil { menu.ErrorHandler(ParseError{newError(err, "Parsing error")}) continue } if len(args) == 0 { - lastLine = line + lastLine = input continue } @@ -83,6 +95,10 @@ func (c *Console) StartContext(ctx context.Context) error { continue } + // Print a newline before executing the command if NewlineBefore is true + // and the last line was not empty. + c.displayPreRun(input) + // 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 @@ -92,7 +108,7 @@ func (c *Console) StartContext(ctx context.Context) error { menu.ErrorHandler(ExecutionError{newError(err, "")}) } - lastLine = line + lastLine = input } } @@ -108,7 +124,7 @@ func (m *Menu) RunCommandArgs(ctx context.Context, args []string) (err error) { m.resetPreRun() // Run the command and associated helpers. - return m.console.Execute(ctx, m, args, !m.console.isExecuting) + return m.console.Execute(ctx, m, args, !m.console.isExecuting.Load()) } // RunCommandLine is the equivalent of menu.RunCommandArgs(), but accepts @@ -136,16 +152,10 @@ func (m *Menu) RunCommandLine(ctx context.Context, line string) (err error) { // command is running, the menu's root command will be overwritten. func (c *Console) Execute(ctx context.Context, menu *Menu, args []string, async bool) error { if !async { - c.mutex.RLock() - c.isExecuting = true - c.mutex.RUnlock() + c.isExecuting.Store(true) } - defer func() { - c.mutex.RLock() - c.isExecuting = false - c.mutex.RUnlock() - }() + defer c.isExecuting.Store(false) // Our root command of interest, used throughout this function. cmd := menu.Command @@ -157,7 +167,6 @@ func (c *Console) Execute(ctx context.Context, menu *Menu, args []string, async return err } - // Reset all flags to their default values. resetFlagsDefaults(target) // Console-wide pre-run hooks, cannot. @@ -175,7 +184,11 @@ func (c *Console) Execute(ctx context.Context, menu *Menu, args []string, async cmd.SetContext(ctx) // Start monitoring keyboard and OS signals. + // signal.Stop releases the channel registration once the command + // returns: without it, every command execution would leak a channel + // in the os/signal package for the lifetime of the process. sigchan := c.monitorSignals() + defer signal.Stop(sigchan) // And start the command execution. go c.executeCommand(cmd, cancel) @@ -251,44 +264,43 @@ func (c *Console) runLineHooks(args []string) ([]string, error) { return processed, nil } -func (c *Console) displayPreRun(line string) { - if c.NewlineBefore { - if !c.NewlineWhenEmpty { - if !c.lineEmpty(line) { - fmt.Fprintln(c.terminal.Out) - } - } else { - fmt.Fprintln(c.terminal.Out) - } +func (c *Console) displayPreRun(input string) { + menu := c.activeMenu() + + if menu.newlineBefore() && (menu.newlineWhenEmpty() || !line.IsEmpty(input, menu.emptyCharSet()...)) { + fmt.Fprintln(c.terminal.Out) } } func (c *Console) displayPostRun(lastLine string) { - if c.NewlineAfter { - if !c.NewlineWhenEmpty { - if !c.lineEmpty(lastLine) { - fmt.Fprintln(c.terminal.Out) - } - } else { - fmt.Fprintln(c.terminal.Out) - } + menu := c.activeMenu() + + if menu.newlineAfter() && (menu.newlineWhenEmpty() || !line.IsEmpty(lastLine, menu.emptyCharSet()...)) { + fmt.Fprintln(c.terminal.Out) } c.printed = false } +// defaultTrapSignals are the OS signals the console traps while a command is +// running when Console.Signals has not been customized. +var defaultTrapSignals = []os.Signal{ + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, +} + // monitorSignals - Monitor the signals that can be sent to the process // while a command is running. We want to be able to cancel the command. -func (c *Console) monitorSignals() <-chan os.Signal { +func (c *Console) monitorSignals() chan os.Signal { sigchan := make(chan os.Signal, 1) - signal.Notify( - sigchan, - syscall.SIGINT, - syscall.SIGTERM, - syscall.SIGQUIT, - // syscall.SIGKILL, - ) + signals := c.Signals + if len(signals) == 0 { + signals = defaultTrapSignals + } + + signal.Notify(sigchan, signals...) return sigchan } diff --git a/console/signals_unix_test.go b/console/signals_unix_test.go new file mode 100644 index 0000000..5796c7b --- /dev/null +++ b/console/signals_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package console + +import ( + "os" + "os/signal" + "syscall" + "testing" + "time" +) + +// TestMonitorSignalsCustom verifies that monitorSignals honors a customized +// Console.Signals set. SIGUSR1 is used because it is not part of the default +// trapped set and is not sent by the test harness. +func TestMonitorSignalsCustom(t *testing.T) { + c := New("test") + c.Signals = []os.Signal{syscall.SIGUSR1} + + ch := c.monitorSignals() + defer signal.Stop(ch) + + if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil { + t.Fatalf("failed to raise SIGUSR1: %v", err) + } + + select { + case got := <-ch: + if got != syscall.SIGUSR1 { + t.Fatalf("received %v, want SIGUSR1", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the custom signal") + } +} diff --git a/docs/readline-console-upstream-sync.md b/docs/readline-console-upstream-sync.md new file mode 100644 index 0000000..875a75a --- /dev/null +++ b/docs/readline-console-upstream-sync.md @@ -0,0 +1,348 @@ +# readline / console upstream sync + +æœŦæ–‡æĄŖį”¨äēŽåŽšæœŸæŠŠ `tui` 内åĩŒįš„ `readline`、`console` fork 同æ­Ĩ到厘斚最新上游īŧŒåšļ把į쓿žœæ˛‰æˇ€å›ž `D:\Programing\go` 下两ä¸Ēé•ŋ期į촿Фåē“。 + +## į›Žæ ‡ + +- 厘斚上游通čŋ‡ git merge čŋ›å…ĨæœŦ地įģ´æŠ¤åˆ†æ”¯īŧŒéŋ免手动čφᛖå¯ŧč‡´åŽ†å˛æ–­čŖ‚ã€‚ +- `D:\Programing\go\readline` 和 `D:\Programing\go\console` äŋæŒä¸ē可持įģ­æ›´æ–°įš„é•ŋ期 fork。 +- `D:\Programing\go\chainreactors\tui\readline` 和 `D:\Programing\go\chainreactors\tui\console` åĒæŽĨæ”ļ厞įģ merge、æĩ‹č¯•通čŋ‡įš„į쓿žœã€‚ +- 每æŦĄåŒæ­Ĩ后čƒŊ清æĨšå›žį­”īŧšä¸Šæ¸¸å¸ĻæĨäē†äģ€äšˆã€æœŦ地čŋ˜äŋį•™äē†å“Ēäē› fork 攚动、是åĻ可厉全æŽĨ受。 + +## å›ēåŽščˇ¯åž„ + +| ᔍ途 | čˇ¯åž„ | +| --- | --- | +| tui ä¸ģäģ“åē“ | `D:\Programing\go\chainreactors\tui` | +| readline é•ŋ期åē“ | `D:\Programing\go\readline` | +| console é•ŋ期åē“ | `D:\Programing\go\console` | +| 临æ—ļ merge æ šį›ŽåŊ• | `D:\Programing\go\tui-upstream-merge` | +| readline merge worktree | `D:\Programing\go\tui-upstream-merge\readline` | +| console merge worktree | `D:\Programing\go\tui-upstream-merge\console` | + +## 分支įēĻ厚 + +| äģ“åē“ | 厘斚čŋœį̝ | 厘斚分支 | æœŦ地įģ´æŠ¤åˆ†æ”¯ | 临æ—ļ merge 分支 | +| --- | --- | --- | --- | --- | +| `readline` | `origin=https://github.com/reeflective/readline` | `origin/master` | `iom` | `codex/tui-merge` | +| `console` | `origin=https://github.com/reeflective/console` | `origin/main` | `iom` | `codex/tui-merge` | +| `tui` | `origin=https://github.com/chainreactors/tui` | `origin/master` | feature branch | n/a | + +`iom` 是é•ŋ期įģ´æŠ¤åˆ†æ”¯ã€‚ä¸čĻåœ¨åŽ˜æ–š `master/main` 上攞æœŦ地攚动。 + +## æœŦ地 fork åŋ…éĄģäŋį•™įš„čƒŊ力 + +`readline`: + +- module path: `github.com/chainreactors/tui/readline` +- `terminal` 包和 `NewShellWithTerminal` +- č‡Ē厚䚉 terminal input/output/control、remote carrier 支持 +- active terminal output/control、resize、raw mode 适配 +- paste transformer、bracketed paste normalize、pending input č¯ģ取 +- inline suggestion API å’Œæ¸˛æŸ“ +- clipboard copy/paste å‘Ŋäģ¤ + +`console`: + +- module path: `github.com/chainreactors/tui/console` +- `replace github.com/chainreactors/tui/readline => ../readline` +- `NewWithTerminal` +- terminal-aware `Printf` / `TransientPrintf` / newline 输å‡ē +- paste reference API 和 `ResolvePasteReferences` +- `StartContext` č¯ģå–åŽč§Ŗæž paste reference +- å¯ŧå‡ēįš„ `Console.Execute` +- completion panic recoverã€éšč— `_carapace`、重įŊŽ Cobra/pflag įŠļ态 +- inline suggestion 寚æŽĨ readline +- `Suggestion` å…ŧ厚įąģ型 + +čŋ™äē›čƒŊ力åŋ…éĄģ有回åŊ’æĩ‹č¯•čĻ†į›–ã€‚åŒæ­Ĩ上游后īŧŒåĻ‚æžœį›¸å…ŗæĩ‹č¯•å¤ąč´ĨīŧŒäŧ˜å…ˆåˆ¤æ–­æ˜¯åĻ蝝删/č¯¯æ”šä熿œŦ地 fork čƒŊ力īŧŒä¸čρä¸ēä熿ŽĨå—ä¸Šæ¸¸į›´æŽĨ删除æĩ‹č¯•。 + +## 厚期同æ­Ĩæĩį¨‹ + +åģēčŽŽæ¯æœˆä¸€æŦĄīŧŒæˆ–在 tui release å‰æ‰§čĄŒä¸€æŦĄã€‚ + +### 1. 更新čŋœį̝äŋĄæ¯ + +```powershell +cd D:\Programing\go\readline +git fetch origin --tags + +cd D:\Programing\go\console +git fetch origin --tags + +cd D:\Programing\go\chainreactors\tui +git fetch origin +``` + +### 2. æŖ€æŸĨé•ŋ期å瓿˜¯åĻåš˛å‡€ + +```powershell +cd D:\Programing\go\readline +git status --short --branch + +cd D:\Programing\go\console +git status --short --branch +``` + +åĻ‚æžœ `D:\Programing\go\readline` 或 `D:\Programing\go\console` 有æœĒ提ä礿”šåЍīŧŒä¸čρᛴæŽĨåœ¨åŽŸį›ŽåŊ• merge。äŊŋᔍ䏋éĸįš„ worktree æĩį¨‹īŧŒéŋ免čĻ†į›–į”¨æˆˇåˇĨäŊœã€‚ + +### 3. 准备临æ—ļ merge worktree + +åĻ‚æžœæ—§ worktree 存在īŧŒå…ˆįĄŽčŽ¤æ˛Ąæœ‰æœĒäŋå­˜å†…厚īŧŒå†åˆ é™¤į›ŽåŊ•åšļ pruneīŧš + +```powershell +cd D:\Programing\go\readline +git worktree list +git worktree prune + +cd D:\Programing\go\console +git worktree list +git worktree prune +``` + +创åģēæ–°įš„ merge worktreeīŧš + +```powershell +New-Item -ItemType Directory -Force D:\Programing\go\tui-upstream-merge | Out-Null + +cd D:\Programing\go\readline +git worktree add -B codex/tui-merge D:\Programing\go\tui-upstream-merge\readline iom + +cd D:\Programing\go\console +git worktree add -B codex/tui-merge D:\Programing\go\tui-upstream-merge\console iom +``` + +### 4. 把åŊ“前 tui 内åĩŒåē“同æ­Ĩä¸ē merge čĩˇį‚š + +čŋ™ä¸€æ­ĨåĒᔍäēŽčŽŠé•ŋ期åē“å‡†įĄŽäģŖčĄ¨åŊ“前 `tui` 内åĩŒįŠļ态īŧŒį„ļ后再 merge 厘斚上游。 + +先 dry-runīŧš + +```powershell +robocopy D:\Programing\go\chainreactors\tui\readline D:\Programing\go\tui-upstream-merge\readline /MIR /XD .git .idea /L +robocopy D:\Programing\go\chainreactors\tui\console D:\Programing\go\tui-upstream-merge\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe /L +``` + +įĄŽčŽ¤čž“å‡ēįŦĻ合éĸ„æœŸåŽæ‰§čĄŒīŧš + +```powershell +robocopy D:\Programing\go\chainreactors\tui\readline D:\Programing\go\tui-upstream-merge\readline /MIR /XD .git .idea +if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 } + +robocopy D:\Programing\go\chainreactors\tui\console D:\Programing\go\tui-upstream-merge\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe +if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 } +``` + +提äē¤åŊ“前 tui fork baselineīŧš + +```powershell +cd D:\Programing\go\tui-upstream-merge\readline +git add -A +git commit -m "sync tui readline fork state" + +cd D:\Programing\go\tui-upstream-merge\console +git add -A +git commit -m "sync tui console fork state" +``` + +åĻ‚æžœæ˛Ąæœ‰å˜åŒ–īŧŒ`git commit` äŧšæį¤ē nothing to commitīŧŒå¯äģĨįģ§įģ­ä¸‹ä¸€æ­Ĩ。 + +### 5. merge 厘斚上游 + +```powershell +cd D:\Programing\go\tui-upstream-merge\readline +git merge --no-edit origin/master + +cd D:\Programing\go\tui-upstream-merge\console +git merge --no-edit origin/main +``` + +åĻ‚æžœæœ‰å†˛įǁīŧŒå¤„į†åŽŸåˆ™īŧš + +- éģ˜čޤæŽĨ受厘斚最新į쓿ž„和重构。 +- åĒ重新äŋį•™æœŦæ–‡æĄŖåˆ—å‡ēįš„ fork čƒŊ力。 +- 不äŋį•™ `.idea`、æœŦ地äēŒčŋ›åˆļ、临æ—ļ文äģļ。 +- 不ä¸ēäē†čŋ‡å†˛įĒč€Œå›žé€€ä¸Šæ¸¸æĩ‹č¯•、åšļ发äŋŽå¤ã€signal äŋŽå¤ã€display/refactor。 + +æŖ€æŸĨ冞įĒæ˜¯åĻ清厌īŧš + +```powershell +git diff --name-only --diff-filter=U +rg -n "<<<<<<<|=======|>>>>>>>" -S . +``` + +æŗ¨æ„īŧš`rg` 可čƒŊ匚配晎通字įŦĻä¸˛é‡Œįš„ `=======`īŧŒäž‹åĻ‚å¸ŽåŠŠæ–‡æœŦ。äģĨ `git diff --name-only --diff-filter=U` ä¸ēįŠēä¸ē准。 + +### 6. æ ŧåŧåŒ–ã€æ•´į†äžčĩ–、æĩ‹č¯• + +`readline`: + +```powershell +cd D:\Programing\go\tui-upstream-merge\readline +$files = rg --files -g '*.go' +if ($files) { gofmt -w $files } +go mod tidy +go test ./... +``` + +`console`: + +```powershell +cd D:\Programing\go\tui-upstream-merge\console +$files = rg --files -g '*.go' +if ($files) { gofmt -w $files } +go mod tidy +go test ./... +``` + +é‡į‚šå…ŗæŗ¨ fork 回åŊ’æĩ‹č¯•īŧš + +- `readline/fork_test.go` +- `readline/terminal/fork_test.go` +- `console/fork_test.go` +- `console/paste_test.go` + +čŋ™ä盿ĩ‹č¯•ᔍäēŽé˜˛æ­ĸ上游同æ­Ĩæ—ļ删掉æœŦ地 terminal、paste、inline suggestion、Execute į­‰åŠŸčƒŊ。 + +䞝čĩ–原则īŧš + +- 不因ä¸ē `go mod tidy` 无意升įē§äžčĩ–。 +- åĻ‚æžœåĒ是åˇĨå…ˇé“žč§Ŗæžå¯ŧ致 `golang.org/x/sys`、`golang.org/x/exp` 升įē§īŧŒäŧ˜å…ˆå›ēåŽšå›žåŽ˜æ–šä¸Šæ¸¸į‰ˆæœŦ。 +- åĻ‚æžœæœŦ地 fork 新čƒŊåŠ›įĄŽåŽžéœ€čĻæ–°åĸžäžčĩ–īŧŒčްåŊ•原因。 + +### 7. 提äē¤ merge į쓿žœ + +```powershell +cd D:\Programing\go\tui-upstream-merge\readline +git add -A +git commit -m "merge upstream readline" + +cd D:\Programing\go\tui-upstream-merge\console +git add -A +git commit -m "merge upstream console" +``` + +åĻ‚æžœåˇ˛įģæäē¤åŽåˆäŋŽäē†äžčĩ–或æĩ‹č¯•īŧŒå¯į”¨īŧš + +```powershell +git add -A +git commit --amend --no-edit +``` + +### 8. 更新é•ŋ期 `iom` 分支 + +åĒ有åŊ“åŽŸį›ŽåŊ•åˇĨäŊœåŒēåš˛å‡€æ—￉§čĄŒã€‚ + +```powershell +cd D:\Programing\go\readline +git status --short --branch +git switch iom +git merge --ff-only codex/tui-merge + +cd D:\Programing\go\console +git status --short --branch +git switch iom +git merge --ff-only codex/tui-merge +``` + +åĻ‚æžœåŽŸį›ŽåŊ•ä¸åš˛å‡€īŧŒå…ˆä¸čĻæ›´æ–° `iom`。äŋį•™ worktree merge commitīŧŒį­‰į”¨æˆˇæ”šåŠ¨å¤„į†åŽŒåŽå† fast-forward。 + +### 9. copy 回 tui + +先äģŽ `master` 新åģē PR 分支īŧš + +```powershell +cd D:\Programing\go\chainreactors\tui +git switch master +git pull --ff-only +git switch -c chore/sync-readline-console-upstream +``` + +先 dry-runīŧš + +```powershell +robocopy D:\Programing\go\tui-upstream-merge\readline D:\Programing\go\chainreactors\tui\readline /MIR /XD .git .idea /L +robocopy D:\Programing\go\tui-upstream-merge\console D:\Programing\go\chainreactors\tui\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe /L +``` + +įĄŽčŽ¤åŽæ‰§čĄŒīŧš + +```powershell +robocopy D:\Programing\go\tui-upstream-merge\readline D:\Programing\go\chainreactors\tui\readline /MIR /XD .git .idea +if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 } + +robocopy D:\Programing\go\tui-upstream-merge\console D:\Programing\go\chainreactors\tui\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe +if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 } +``` + +### 10. 在 tui 中æĩ‹č¯•和提äē¤ + +```powershell +cd D:\Programing\go\chainreactors\tui +go test ./... +git status --short +git add readline console +git commit -m "sync readline and console with upstream" +``` + +åĻ‚æžœ `go.mod` / `go.sum` å› å­æ¨Ąå—å˜åŒ–éœ€čĻæ›´æ–°īŧŒå†å•į‹ŦæŖ€æŸĨ后加å…Ĩ commit。 + +### 11. 创åģē PR + +```powershell +git push -u origin chore/sync-readline-console-upstream +``` + +PR 描čŋ°åģēčŽŽåŒ…åĢīŧš + +- 厘斚 upstream commitīŧš + - readline: `git -C D:\Programing\go\tui-upstream-merge\readline rev-parse --short origin/master` + - console: `git -C D:\Programing\go\tui-upstream-merge\console rev-parse --short origin/main` +- æœŦ地 merge commitīŧš + - readline: `git -C D:\Programing\go\tui-upstream-merge\readline rev-parse --short HEAD` + - console: `git -C D:\Programing\go\tui-upstream-merge\console rev-parse --short HEAD` +- æĩ‹č¯•į쓿žœīŧš + - `go test ./...` in readline + - `go test ./...` in console + - `go test ./...` in tui +- äŋį•™įš„æœŦ地 fork čƒŊ力。 +- Go version 变化。åĻ‚æžœåŽ˜æ–šå­æ¨Ąå— `go.mod` 提升äē† `go` directiveīŧŒæ˜ŽįĄŽč¯´æ˜Žæ˜¯åĻæŽĨ受。 + +## åŋĢé€ŸåˇŽåŧ‚æŖ€æŸĨ + +æŸĨįœ‹ fork 与厘斚最新čŋ˜åˇŽå“Ēä盿–‡äģļīŧš + +```powershell +cd D:\Programing\go\tui-upstream-merge\readline +git diff --shortstat origin/master HEAD +git diff --name-status origin/master HEAD + +cd D:\Programing\go\tui-upstream-merge\console +git diff --shortstat origin/main HEAD +git diff --name-status origin/main HEAD +``` + +æŸĨįœ‹æœŦ地äŋį•™æäē¤å’ŒåŽ˜æ–šæäē¤īŧš + +```powershell +cd D:\Programing\go\tui-upstream-merge\readline +git log --oneline --decorate --left-right --cherry-pick HEAD...origin/master + +cd D:\Programing\go\tui-upstream-merge\console +git log --oneline --decorate --left-right --cherry-pick HEAD...origin/main +``` + +## åŊ“å‰åˇ˛įŸĨįŠļ态 + +2026-06-27 åˇ˛åŽŒæˆä¸€æŦĄ merge énj蝁īŧš + +- `readline` + - official latest: `088046b` + - merge result: `916e20f` + - test: `go test ./...` passed +- `console` + - official latest: `7002774` + - merge result: `92517a0` + - test: `go test ./...` passed + +æŗ¨æ„īŧšåŊ“前厘斚 `readline` / `console` å­æ¨Ąå—ä¸ē `go 1.25.0`īŧŒč€Œ `tui` æ šæ¨Ąå—æ˜¯ `go 1.24.2`。copy 回 `tui` åšļ发 PR 前īŧŒéœ€čĻįĄŽčŽ¤æ˜¯åĻæŽĨå—å­æ¨Ąå— Go į‰ˆæœŦčĻæą‚ã€‚ diff --git a/readline/.github/workflows/codacy.yml b/readline/.github/workflows/codacy.yml index a5208e1..6b1b70a 100644 --- a/readline/.github/workflows/codacy.yml +++ b/readline/.github/workflows/codacy.yml @@ -36,7 +36,7 @@ jobs: steps: # Checkout the repository to the GitHub Actions runner - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis - name: Run Codacy Analysis CLI diff --git a/readline/.github/workflows/codeql.yml b/readline/.github/workflows/codeql.yml index a7ed8ae..4d538e6 100644 --- a/readline/.github/workflows/codeql.yml +++ b/readline/.github/workflows/codeql.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/readline/.github/workflows/dependency-review.yml b/readline/.github/workflows/dependency-review.yml index 0d4a013..3ea91b4 100644 --- a/readline/.github/workflows/dependency-review.yml +++ b/readline/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 diff --git a/readline/.github/workflows/go.yml b/readline/.github/workflows/go.yml index 6889e1e..211c747 100644 --- a/readline/.github/workflows/go.yml +++ b/readline/.github/workflows/go.yml @@ -18,12 +18,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: 1.22.6 + go-version-file: go.mod - name: Build run: go build -v ./... @@ -37,17 +37,17 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: 1.22.6 + go-version-file: go.mod - name: Build run: go build -v ./... shell: powershell - # - name: Run coverage - # run: go test -v ./... - # shell: powershell + - name: Run coverage + run: go test -v ./... + shell: powershell diff --git a/readline/.gitignore b/readline/.gitignore new file mode 100644 index 0000000..358d197 --- /dev/null +++ b/readline/.gitignore @@ -0,0 +1,3 @@ +.gemini + +readline.wiki/ diff --git a/readline/.golangci.yml b/readline/.golangci.yml new file mode 100644 index 0000000..d2e4f09 --- /dev/null +++ b/readline/.golangci.yml @@ -0,0 +1,177 @@ +# Repo-local golangci-lint configuration for reeflective/readline. +# +# Derived from the shared reeflective config, with a few project-specific tunes: +# - varnamelen/goconst/mnd disabled: short names in short scopes are idiomatic +# here, and bulk literal/number extraction adds churn without value. +# - gocognit threshold raised to 30 to match cyclop/gocyclo; the few genuinely +# complex hot-path parsers/display functions are intentionally dense. +# - nestif relaxed slightly (5 -> 7); the flagged blocks are shallow. +# - funlen/gocognit excluded for tests (table-driven tests are naturally long). +# - godox disabled: TODO/FIXME markers are kept intentionally as tracking notes. +version: "2" +run: + modules-download-mode: readonly + tests: true + allow-parallel-runners: true +linters: + enable: + - asasalint + - bidichk + - bodyclose + - canonicalheader + - containedctx + - contextcheck + - copyloopvar + - cyclop + - decorder + - dogsled + - dupl + - dupword + - durationcheck + - err113 + - errchkjson + - errname + - errorlint + - exptostd + - fatcontext + - forcetypeassert + - funlen + - ginkgolinter + - gocheckcompilerdirectives + - gochecksumtype + - gocognit + - gocritic + - gocyclo + - godot + - goheader + - gomoddirectives + - goprintffuncname + - gosec + - gosmopolitan + - grouper + - iface + - importas + - inamedparam + - interfacebloat + - intrange + - ireturn + - loggercheck + - maintidx + - makezero + - mirror + - misspell + - nakedret + - nestif + - nilerr + - nilnesserr + - nilnil + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - perfsprint + - prealloc + - predeclared + - protogetter + - reassign + - recvcheck + - revive + - sloglint + - spancheck + - tagalign + - tagliatelle + - testableexamples + - testifylint + - thelper + - tparallel + - unconvert + - unparam + - usestdlibvars + - usetesting + - wastedassign + - wsl + - zerologlint + disable: + - asciicheck + - depguard + - exhaustive + - exhaustruct + - forbidigo + - gochecknoglobals + - gochecknoinits + - goconst + - godox + - gomodguard + - lll + - mnd + - musttag + - nlreturn + - nonamedreturns + - promlinter + - rowserrcheck + - sqlclosecheck + - testpackage + - varnamelen + - whitespace + - wrapcheck + settings: + cyclop: + max-complexity: 30 + package-average: 10 + errcheck: + check-type-assertions: true + exhaustive: + check: + - switch + - map + funlen: + lines: 100 + statements: 50 + gocognit: + min-complexity: 30 + nestif: + min-complexity: 7 + paralleltest: + ignore-missing: true + wsl: + allow-separated-leading-comment: true + allow-cuddle-declarations: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - dupl + - errcheck + - funlen + - gocognit + - gocyclo + - gosec + path: _test\.go + - linters: + - lll + source: '^//go:generate ' + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-same-issues: 0 +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - github.com/reeflective + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/readline/README.md b/readline/README.md index 22d6629..2f81384 100644 --- a/readline/README.md +++ b/readline/README.md @@ -4,23 +4,23 @@

- - + Github Actions (workflows) - + Go module version - + GoDoc reference - - + Go Report Card @@ -48,17 +48,17 @@ It is used, between others, to power the [console](https://github.com/reeflectiv - Cross-platform (Linux / MacOS / Windows) - Full `.inputrc` support (all commands/options) - Extensive test suite and almost full coverage of core code -- [Extended list](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands) of additional commands/options (edition/completion/history) -- Complete [multiline edition/movement support](https://github.com/reeflective/readline/wiki/Multiline) +- [Extended list](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands) of additional commands/options (edition/completion/history) +- Complete [multiline edition/movement support](https://github.com/chainreactors/tui/readline/wiki/Multiline) - Command-line edition in `$EDITOR`/`$VISUAL` support -- [Programmable API](https://github.com/reeflective/readline/wiki/Programmable-Commands), with failure-safe access to core components -- Support for an [arbitrary number of history sources](https://github.com/reeflective/readline/wiki/History-Sources) +- [Programmable API](https://github.com/chainreactors/tui/readline/wiki/Programmable-Commands), with failure-safe access to core components +- Support for an [arbitrary number of history sources](https://github.com/chainreactors/tui/readline/wiki/History-Sources) ### Emacs / Standard - Native Emacs commands -- Emacs-style [macro engine](https://github.com/reeflective/readline/wiki/Macros#emacs) (not working across multiple calls) -- Keywords [switching](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands#modifying-text) (operators, booleans, hex/binary/digit) with iterations +- Emacs-style [macro engine](https://github.com/chainreactors/tui/readline/wiki/Macros#emacs) (not working across multiple calls) +- Keywords [switching](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands#modifying-text) (operators, booleans, hex/binary/digit) with iterations - Command/mode cursor status indicator - Complete undo/redo history - Command status/arg/iterations hint display @@ -66,101 +66,101 @@ It is used, between others, to power the [console](https://github.com/reeflectiv ### Vim - Near-native Vim mode -- Vim [text objects](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands#text-objects) (code blocks, words/blank/shellwords) +- Vim [text objects](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands#text-objects) (code blocks, words/blank/shellwords) - Extended surround select/change/add functionality, with highlighting - Vim Visual/Operator pending mode & cursor styles indications - Vim Insert and Replace (once/many) - All Vim registers, with completion support -- [Vim-style](https://github.com/reeflective/readline/wiki/Macros#vim) macro recording (`q`) and invocation (`@`) +- [Vim-style](https://github.com/chainreactors/tui/readline/wiki/Macros#vim) macro recording (`q`) and invocation (`@`) ### Interface -- Support for PS1/PS2/RPROMPT/transient/tooltip [prompts](https://github.com/reeflective/readline/wiki/Prompts) (compatible with [oh-my-posh](https://github.com/JanDeDobbeleer/oh-my-posh)) -- Extended completion system, [keymap-based and configurable](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands#completion), easy to populate & use +- Support for PS1/PS2/RPROMPT/transient/tooltip [prompts](https://github.com/chainreactors/tui/readline/wiki/Prompts) (compatible with [oh-my-posh](https://github.com/JanDeDobbeleer/oh-my-posh)) +- Extended completion system, [keymap-based and configurable](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands#completion), easy to populate & use - Multiple completion display styles, with color support. - Completion & History incremental search system & highlighting (fuzzy-search). - Automatic & context-aware suffix removal for efficient flags/path/list completion. - Optional asynchronous autocomplete -- Builtin & programmable [syntax highlighting](https://github.com/reeflective/readline/wiki/Syntax-Highlighting) +- Builtin & programmable [syntax highlighting](https://github.com/chainreactors/tui/readline/wiki/Syntax-Highlighting) ## Documentation Readline is used by the [console library](https://github.com/reeflective/console) and its [example binary](https://github.com/reeflective/console/tree/main/example). To get a grasp of the functionality provided by readline and its default configuration, install and start the binary. -The documentation is available on the [repository wiki](https://github.com/reeflective/readline/wiki), for both users and developers. +The documentation is available on the [repository wiki](https://github.com/chainreactors/tui/readline/wiki), for both users and developers. ## Showcases

- Emacs edition
(This extract is quite a pity, because its author is not using Emacs and does not know many of its shortcuts)
- +
- Vim edition - +
- Undo/redo line history - +
- Keyword switching & selection
Switching various keywords
- +
Using regexp-based selection to grab parts of words (here, URL components)
- +
- Vim selection & movements (basic) - +
- Vim surround (selection and change)
Selecting/adding/changing surround regions
- +
Surround and change in shellwords, matching brackets, etc.
- +
- Vim registers (with completion) - +
- History movements/completion/use/search
History movement, completion and some other other widgets
- +
- Completion
Classic mode & incremental search mode
- +
Suffix-autoremoval
- +
- Prompts - +
- Logging - +
- Inputrc init file reload - +
- Multiline edition - +
- Macros
Emacs
- +
Vim
- +
## Status diff --git a/readline/commands_test.go b/readline/commands_test.go new file mode 100644 index 0000000..6e482a6 --- /dev/null +++ b/readline/commands_test.go @@ -0,0 +1,46 @@ +package readline + +import ( + "maps" + "testing" +) + +// allCommands aggregates every command set the shell registers with its keymap +// engine (see Shell.init in shell.go). The builders only construct maps of +// method values, so a zero Shell is enough to enumerate them. +func allCommands() commands { + rl := new(Shell) + + all := make(commands) + for _, set := range []commands{ + rl.standardCommands(), + rl.viCommands(), + rl.historyCommands(), + rl.completionCommands(), + } { + maps.Copy(all, set) + } + + return all +} + +// TestArrowNavigationCommandsRegistered guards that every line/history +// navigation action bound by the library's own default keymaps resolves to a +// registered command. The vi-insert down-arrow binding (down-line-or-search) +// previously resolved to nothing and was a silent no-op; this is its +// regression guard, plus its siblings so the whole arrow-nav set stays wired. +func TestArrowNavigationCommandsRegistered(t *testing.T) { + all := allCommands() + + for _, action := range []string{ + "up-line-or-search", + "down-line-or-search", + "up-line-or-history", + "down-line-or-history", + "down-line-or-select", + } { + if _, ok := all[action]; !ok { + t.Errorf("navigation action %q is bound in the default keymaps but has no registered command", action) + } + } +} diff --git a/readline/completion.go b/readline/completion.go index ebdeb56..6cb2c03 100644 --- a/readline/completion.go +++ b/readline/completion.go @@ -57,6 +57,9 @@ func (rl *Shell) completeWord() { rl.startMenuComplete(rl.commandCompletion) if rl.Config.GetBool("menu-complete-display-prefix") { + // Insert the prefix shared by all candidates, then display the + // menu without selecting one (GNU menu-complete-display-prefix). + rl.completer.InsertCommonPrefix() return } } @@ -82,8 +85,10 @@ func (rl *Shell) insertCompletions() { } // Insert each match, cancel insertion with preserving - // the candidate just inserted in the line, for each. - for i := 0; i < rl.completer.Matches(); i++ { + // the candidate just inserted in the line, for each. The match count is + // invariant here: Select only moves the highlight and Cancel(false,false) + // only restores the line buffer, neither alters the candidate groups. + for range rl.completer.Matches() { rl.completer.Select(1, 0) rl.completer.Cancel(false, false) } @@ -114,6 +119,8 @@ func (rl *Shell) menuComplete() { // Immediately select only if not asked to display first. if rl.Config.GetBool("menu-complete-display-prefix") { + // Insert the prefix shared by all candidates before displaying. + rl.completer.InsertCommonPrefix() return } } @@ -220,6 +227,22 @@ func (rl *Shell) menuIncrementalSearch() { rl.completer.IsearchStart("completions", false, false) } +// RefreshCompletions regenerates the currently active completion menu from the +// cached completer and repaints, so completions produced asynchronously (for +// instance by a background producer that updates a cache the completer reads) +// can be shown in place without the user pressing a key. +// +// It is safe to call from any goroutine. If no completion menu is active it is +// a clean no-op. The regeneration runs on the Readline goroutine (rendering +// stays single-writer). If the user has a candidate selected, that selection is +// preserved across the refresh: the same candidate (matched by tag+value) is +// re-selected in the rebuilt menu, or, if it no longer exists in the new +// results, the menu is left active with no selection. +func (rl *Shell) RefreshCompletions() { + rl.completer.RequestRegen() + rl.Keys.RequestRefresh() +} + // // Utilities -------------------------------------------------------------------------- // diff --git a/readline/completions.go b/readline/completions.go index f193d80..361eadd 100644 --- a/readline/completions.go +++ b/readline/completions.go @@ -13,6 +13,8 @@ type Completion = completion.Candidate // including usage strings, messages, and suffix matchers for autoremoval. // Some of those additional settings will apply to all contained candidates, // except when these candidates have their own corresponding settings. +// +//nolint:recvcheck // Fluent builder: value-receiver setters; only EachValue/merge/convert use a pointer (intentional). type Completions struct { values completion.RawValues messages completion.Messages @@ -106,7 +108,7 @@ func CompleteStyledValuesDescribed(values ...string) Completions { return Completions{values: vals} } -// CompleteMessage ads a help message to display along with +// CompleteMessage adds a help message to display along with // or in places where no completions can be generated. func CompleteMessage(msg string, args ...any) Completions { comps := Completions{} @@ -125,19 +127,6 @@ func CompleteRaw(values []Completion) Completions { return Completions{values: completion.RawValues(values)} } -// Message displays a help messages in places where no completions can be generated. -func Message(msg string, args ...any) Completions { - comps := Completions{} - - if len(args) > 0 { - msg = fmt.Sprintf(msg, args...) - } - - comps.messages.Add(msg) - - return comps -} - // Suppress suppresses specific error messages using regular expressions. func (c Completions) Suppress(expr ...string) Completions { if err := c.messages.Suppress(expr...); err != nil { @@ -206,7 +195,7 @@ func (c Completions) UsageF(f func() string) Completions { // CompleteValues("yes").Style("35") // CompleteValues("no").Style("255") func (c Completions) Style(style string) Completions { - return c.StyleF(func(s string) string { + return c.StyleF(func(_ string) string { return style }) } @@ -239,7 +228,7 @@ func (c Completions) StyleF(f func(s string) string) Completions { // // CompleteValues("192.168.1.1", "127.0.0.1").Tag("interfaces"). func (c Completions) Tag(tag string) Completions { - return c.TagF(func(value string) string { + return c.TagF(func(_ string) string { return tag }) } diff --git a/readline/emacs.go b/readline/emacs.go index 402aa20..a2acc26 100644 --- a/readline/emacs.go +++ b/readline/emacs.go @@ -8,13 +8,15 @@ import ( "unicode" "github.com/atotto/clipboard" + "github.com/rivo/uniseg" + "github.com/chainreactors/tui/readline/inputrc" "github.com/chainreactors/tui/readline/internal/color" "github.com/chainreactors/tui/readline/internal/completion" + "github.com/chainreactors/tui/readline/internal/core" "github.com/chainreactors/tui/readline/internal/keymap" "github.com/chainreactors/tui/readline/internal/strutil" "github.com/chainreactors/tui/readline/internal/term" - "github.com/rivo/uniseg" ) // standardCommands returns all standard/emacs commands. @@ -34,9 +36,6 @@ func (rl *Shell) standardCommands() commands { // Modes "emacs-editing-mode": rl.emacsEditingMode, - "copy-to-clipboard": rl.copyToClipboard, - "paste-from-clipboard": rl.pasteFromClipboard, - // Moving "forward-char": rl.forwardChar, "backward-char": rl.backwardChar, @@ -61,6 +60,7 @@ func (rl *Shell) standardCommands() commands { "tab-insert": rl.tabInsert, "self-insert": rl.selfInsert, "bracketed-paste-begin": rl.bracketedPasteBegin, + "skip-csi-sequence": rl.skipCsiSequence, "transpose-chars": rl.transposeChars, "transpose-words": rl.transposeWords, "shell-transpose-words": rl.shellTransposeWords, @@ -70,11 +70,13 @@ func (rl *Shell) standardCommands() commands { "overwrite-mode": rl.overwriteMode, "delete-horizontal-whitespace": rl.deleteHorizontalWhitespace, - "delete-word": rl.deleteWord, - "quote-region": rl.quoteRegion, - "quote-line": rl.quoteLine, - "keyword-increase": rl.keywordIncrease, - "keyword-decrease": rl.keywordDecrease, + "delete-word": rl.deleteWord, + "quote-region": rl.quoteRegion, + "quote-line": rl.quoteLine, + "keyword-increase": rl.keywordIncrease, + "keyword-decrease": rl.keywordDecrease, + "copy-to-clipboard": rl.copyToClipboard, + "paste-from-clipboard": rl.pasteFromClipboard, // Killing & yanking "kill-line": rl.killLine, @@ -292,8 +294,8 @@ func (rl *Shell) downLine() { func (rl *Shell) clearScreen() { rl.History.SkipSave() - term.Print(term.CursorTopLeft) - term.Print(term.ClearScreen) + fmt.Print(term.CursorTopLeft) + fmt.Print(term.ClearScreen) rl.Display.PrintPrimaryPrompt() } @@ -303,8 +305,8 @@ func (rl *Shell) clearScreen() { func (rl *Shell) clearDisplay() { rl.History.SkipSave() - term.Print(term.CursorTopLeft) - term.Print(term.ClearDisplay) + fmt.Print(term.CursorTopLeft) + fmt.Print(term.ClearDisplay) rl.Display.PrintPrimaryPrompt() } @@ -424,10 +426,7 @@ func (rl *Shell) selfInsert() { rl.completer.TrimSuffix() key := rl.Keys.Caller() - if len(key) == 0 { - // todo: temp fix - return - } + // Handle autopair insertion (for the closer only) searching, _, _ := rl.completer.NonIncrementallySearching() isearch := rl.Keymap.Local() == keymap.Isearch @@ -440,6 +439,7 @@ func (rl *Shell) selfInsert() { var quoted []rune var length int + if rl.Config.GetBool("output-meta") && key[0] != inputrc.Esc { quoted = append(quoted, key[0]) length = uniseg.StringWidth(string(quoted)) @@ -456,6 +456,37 @@ func (rl *Shell) bracketedPasteBegin() { rl.InsertPastedText(string(rl.Keys.ReadUntilSequence([]byte(BracketedPasteEnd)))) } +// skipCsiSequence consumes the remainder of a CSI escape sequence (the GNU +// readline skip-csi-sequence command). Terminals encode many special keys as +// "ESC [" followed by parameter/intermediate bytes (0x20-0x3F) and a single +// final byte (0x40-0x7E) -- e.g. F5 is "\e[15~", Shift-Tab is "\e[Z". When such +// a key has no binding, its trailing bytes would otherwise be self-inserted as +// stray characters. Bound to "\e[", this command catches any CSI sequence that +// no longer/exact binding claimed and swallows the rest of it, so unrecognised +// keys do nothing. +// +// It is intentionally left unbound by default (as in GNU readline); enable it +// from inputrc by binding the sequence "\e[" to the skip-csi-sequence command. +func (rl *Shell) skipCsiSequence() { + rl.History.SkipSave() + + // Whatever the dispatcher already consumed of the sequence, drain the rest: + // keep popping parameter/intermediate bytes, and stop once we consume the + // terminating byte (the final byte, or any non-CSI byte). The keys of a CSI + // sequence arrive in a single terminal read, so they are already buffered; + // if the buffer empties we simply stop rather than block on a partial one. + for { + key, empty := core.PopKey(rl.Keys) + if empty { + return + } + + if key < 0x20 || key >= 0x40 { + return + } + } +} + // Drag the character before point forward over the character // at point, moving point forward as well. If point is at the // end of the line, then this transposes the two characters @@ -1238,7 +1269,7 @@ func (rl *Shell) abort() { key := rl.Keys.Caller() if key[0] == rune(inputrc.Unescape(`\C-C`)[0]) { quoted, _ := strutil.Quote(key[0]) - term.Print(string(quoted)) + fmt.Print(string(quoted)) } } @@ -1435,7 +1466,7 @@ func (rl *Shell) insertComment() { // can be made part of an inputrc file. func (rl *Shell) dumpFunctions() { rl.Display.ClearHelpers() - term.Println() + fmt.Println() defer func() { rl.Prompt.PrimaryPrint() @@ -1452,7 +1483,7 @@ func (rl *Shell) dumpFunctions() { // can be made part of an inputrc file. func (rl *Shell) dumpVariables() { rl.Display.ClearHelpers() - term.Println() + fmt.Println() defer func() { rl.Prompt.PrimaryPrint() @@ -1460,7 +1491,7 @@ func (rl *Shell) dumpVariables() { }() // Get all variables and their values, alphabetically sorted. - var variables []string + variables := make([]string, 0, len(rl.Config.Vars)) for variable := range rl.Config.Vars { variables = append(variables, variable) @@ -1472,12 +1503,12 @@ func (rl *Shell) dumpVariables() { if rl.Iterations.IsSet() { for _, variable := range variables { value := rl.Config.Vars[variable] - term.Printf("set %s %v\n", variable, value) + fmt.Printf("set %s %v\n", variable, value) } } else { for _, variable := range variables { value := rl.Config.Vars[variable] - term.Printf("%s is set to `%v'\n", variable, value) + fmt.Printf("%s is set to `%v'\n", variable, value) } } } @@ -1488,7 +1519,7 @@ func (rl *Shell) dumpVariables() { // can be made part of an inputrc file. func (rl *Shell) dumpMacros() { rl.Display.ClearHelpers() - term.Println() + fmt.Println() defer func() { rl.Prompt.PrimaryPrint() @@ -1514,12 +1545,12 @@ func (rl *Shell) dumpMacros() { if rl.Iterations.IsSet() { for _, key := range macroBinds { action := inputrc.Escape(binds[inputrc.Unescape(key)].Action) - term.Printf("\"%s\": \"%s\"\n", key, action) + fmt.Printf("\"%s\": \"%s\"\n", key, action) } } else { for _, key := range macroBinds { action := inputrc.Escape(binds[inputrc.Unescape(key)].Action) - term.Printf("%s outputs %s\n", key, action) + fmt.Printf("%s outputs %s\n", key, action) } } } @@ -1624,35 +1655,32 @@ func (rl *Shell) selectKeywordPrev() { rl.selection.Visual(false) } -// copyToClipboard 将åŊ“å‰é€‰ä¸­įš„å†…åŽšæˆ–æ•´čĄŒå¤åˆļ到įŗģįģŸå‰Ēč´´æŋ +// copyToClipboard copies the current selection, or the whole line when nothing +// is selected, to the system clipboard. func (rl *Shell) copyToClipboard() { - rl.History.SkipSave() - - // æŖ€æŸĨ是åĻæœ‰é€‰ä¸­įš„æ–‡æœŦ var text string if rl.selection.Active() { text = rl.selection.Text() } else { - // åĻ‚æžœæ˛Ąæœ‰é€‰ä¸­īŧŒå¤åˆļæ•´čĄŒ text = string(*rl.line) } - - // 将文æœŦ复åˆļ到å‰Ēč´´æŋ - clipboard.WriteAll(text) + if text == "" { + rl.History.SkipSave() + return + } + if err := clipboard.WriteAll(text); err != nil { + rl.Hint.SetTemporary(color.FgRed + "copy failed") + return + } + rl.Hint.SetTemporary(color.Dim + "copied") } -// pasteFromClipboard äģŽå‰Ēč´´æŋčŽˇå–å†…åŽšåšļម贴到åŊ“前光标äŊįŊŽ +// pasteFromClipboard inserts text from the system clipboard at the cursor. func (rl *Shell) pasteFromClipboard() { - rl.History.Save() - - // äģŽå‰Ēč´´æŋč¯ģ取内厚 text, err := clipboard.ReadAll() if err != nil { rl.Hint.SetTemporary(color.FgRed + "paste failed") return } - - // 将内厚插å…Ĩ到光标äŊįŊŽ - rl.cursor.InsertAt([]rune(text)...) - rl.Display.Refresh() + rl.InsertPastedText(text) } diff --git a/readline/emacs_test.go b/readline/emacs_test.go new file mode 100644 index 0000000..8c0fcee --- /dev/null +++ b/readline/emacs_test.go @@ -0,0 +1,101 @@ +package readline + +import ( + "testing" + + "github.com/chainreactors/tui/readline/internal/core" + "github.com/chainreactors/tui/readline/internal/history" +) + +// feedPaste builds a minimal shell, feeds a bracketed-paste payload terminated +// by the end sequence, runs the paste handler, and returns the resulting line. +func feedPaste(t *testing.T, payload string) string { + t.Helper() + + line := new(core.Line) + rl := &Shell{ + Keys: new(core.Keys), + line: line, + cursor: core.NewCursor(line), + } + + // The handler consumes keys until it sees the paste-end sequence, so the + // terminator must be fed too — otherwise it would block waiting for input. + rl.Keys.Feed(false, []rune(payload+"\x1b[201~")...) + rl.bracketedPasteBegin() + + return string(*line) +} + +// TestBracketedPasteNormalizesCarriageReturns guards that pasted line breaks, +// which terminals deliver as \r or \r\n, are stored as \n. Raw carriage +// returns corrupt the line buffer and its multiline rendering. +func TestBracketedPasteNormalizesCarriageReturns(t *testing.T) { + cases := []struct { + name string + payload string + want string + }{ + {"crlf", "a\r\nb", "a\nb"}, + {"bare cr", "a\rb", "a\nb"}, + {"mixed", "one\r\ntwo\rthree", "one\ntwo\nthree"}, + {"no newline", "plain", "plain"}, + {"already lf", "a\nb", "a\nb"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := feedPaste(t, c.payload); got != c.want { + t.Fatalf("paste %q => %q, want %q", c.payload, got, c.want) + } + }) + } +} + +// drainKeys pops everything remaining in the key buffer and returns it. +func drainKeys(rl *Shell) string { + var b []byte + + for { + key, empty := core.PopKey(rl.Keys) + if empty { + return string(b) + } + + b = append(b, key) + } +} + +// TestSkipCsiSequence drives skip-csi-sequence over the bytes that remain after +// the dispatcher has matched the leading "\e[": it must swallow the parameter +// bytes and the single final byte, while leaving any following keystrokes +// untouched. +func TestSkipCsiSequence(t *testing.T) { + cases := []struct { + name string + feed string // bytes left in the buffer after "\e[" was matched + wantLeft string // what must remain unconsumed afterwards + }{ + {"function key params and final", "15~", ""}, + {"single final byte", "Z", ""}, + {"modified arrow with params", "1;5D", ""}, + {"stops at final, keeps following keys", "15~abc", "abc"}, + {"empty buffer is a no-op", "", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rl := &Shell{Keys: new(core.Keys), History: &history.Sources{}} + + if c.feed != "" { + rl.Keys.Feed(false, []rune(c.feed)...) + } + + rl.skipCsiSequence() + + if got := drainKeys(rl); got != c.wantLeft { + t.Fatalf("skip of %q left %q, want %q", c.feed, got, c.wantLeft) + } + }) + } +} diff --git a/readline/fork_test.go b/readline/fork_test.go new file mode 100644 index 0000000..f8ab7a0 --- /dev/null +++ b/readline/fork_test.go @@ -0,0 +1,56 @@ +package readline + +import ( + "bytes" + "strings" + "testing" + + rlterm "github.com/chainreactors/tui/readline/terminal" +) + +func TestNewShellWithTerminalUsesCustomInput(t *testing.T) { + term := rlterm.Stream(strings.NewReader("alpha<>beta"), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 40, 10)) + rl := NewShellWithTerminal(term) + + got := string(rl.Keys.ReadUntilSequence([]byte("<>"))) + if got != "alpha" { + t.Fatalf("ReadUntilSequence read %q, want %q", got, "alpha") + } + + rest := string(rl.Keys.Read()) + if rest != "beta" { + t.Fatalf("remaining buffered input = %q, want %q", rest, "beta") + } +} + +func TestShellPrintfUsesTerminalOutput(t *testing.T) { + var out bytes.Buffer + term := rlterm.Stream(strings.NewReader(""), &out, &out, rlterm.NewControl(false, 40, 10)) + rl := NewShellWithTerminal(term) + + if _, err := rl.Printf("hello %s", "terminal"); err != nil { + t.Fatalf("Printf returned error: %v", err) + } + + if !strings.Contains(out.String(), "hello terminal") { + t.Fatalf("terminal output %q does not contain formatted message", out.String()) + } +} + +func TestInlineSuggestionAPI(t *testing.T) { + rl := NewShellWithTerminal(rlterm.Stream(strings.NewReader(""), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 40, 10))) + + rl.SetInlineSuggestion("status --verbose") + if got := rl.GetInlineSuggestion(); got != "status --verbose" { + t.Fatalf("inline suggestion = %q", got) + } + + rl.ClearInlineSuggestion() + if got := rl.GetInlineSuggestion(); got != "" { + t.Fatalf("cleared inline suggestion = %q, want empty", got) + } +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/readline/go.mod b/readline/go.mod index 4ceaff9..b4cf944 100644 --- a/readline/go.mod +++ b/readline/go.mod @@ -1,13 +1,12 @@ module github.com/chainreactors/tui/readline -go 1.21 +go 1.25.0 -require ( - golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 - golang.org/x/sys v0.8.0 -) +require golang.org/x/sys v0.45.0 require ( github.com/atotto/clipboard v0.1.4 - github.com/rivo/uniseg v0.4.4 + github.com/creack/pty v1.1.24 + github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 + github.com/rivo/uniseg v0.4.7 ) diff --git a/readline/go.sum b/readline/go.sum index 994a1e2..8531fe0 100644 --- a/readline/go.sum +++ b/readline/go.sum @@ -1,8 +1,10 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= -golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/readline/history.go b/readline/history.go index a2a873e..797b818 100644 --- a/readline/history.go +++ b/readline/history.go @@ -60,6 +60,7 @@ func (rl *Shell) historyCommands() commands { "vi-down-line-or-history": rl.viDownLineOrHistory, "up-line-or-history": rl.upLineOrHistory, "up-line-or-search": rl.upLineOrSearch, + "down-line-or-search": rl.downLineOrSearch, "down-line-or-select": rl.downLineOrSelect, "infer-next-history": rl.inferNextHistory, "beginning-of-buffer-or-history": rl.beginningOfBufferOrHistory, @@ -439,6 +440,22 @@ func (rl *Shell) upLineOrSearch() { } } +// If the cursor is not on the last line of the buffer, move down a line. +// Otherwise, search forward in the history for a line matching the buffer. +// This is the symmetric counterpart of up-line-or-search; without it the +// default vi-insert down-arrow binding resolved to an unregistered command +// and did nothing. +func (rl *Shell) downLineOrSearch() { + rl.History.SkipSave() + + switch { + case rl.cursor.LinePos() < rl.line.Lines(): + rl.cursor.LineMove(1) + default: + rl.historySearchForward() + } +} + // If the cursor is on the last line of the buffer, start an incremental // search forward on the history lines. Otherwise, move up a line in the buffer. func (rl *Shell) downLineOrSelect() { diff --git a/readline/inputrc/inputrc_test.go b/readline/inputrc/inputrc_test.go index c4264a2..219f75c 100644 --- a/readline/inputrc/inputrc_test.go +++ b/readline/inputrc/inputrc_test.go @@ -3,6 +3,7 @@ package inputrc import ( "bytes" "embed" + "errors" "fmt" "io/fs" "os/user" @@ -166,11 +167,13 @@ func TestDecodeKey(t *testing.T) { func newConfig() (*Config, map[string][]string) { cfg := NewDefaultConfig(WithConfigReadFileFunc(readTestdata)) keys := make(map[string][]string) - cfg.Funcs["$custom"] = func(k, v string) error { + // The error return is mandated by the Config.Funcs signature, so unparam's + // "always nil" is expected here. + cfg.Funcs["$custom"] = func(k, v string) error { //nolint:unparam keys[k] = append(keys[k], v) return nil } - cfg.Funcs[""] = func(k, v string) error { + cfg.Funcs[""] = func(k, v string) error { //nolint:unparam keys[k] = append(keys[k], v) return nil } @@ -186,6 +189,9 @@ func readTest(t *testing.T, name string) [][]byte { t.Fatalf("expected no error, got: %v", err) } + // Correct for Windows line endings, as test files are embedded. + buf = bytes.ReplaceAll(buf, []byte("\r\n"), []byte("\n")) + return bytes.Split(buf, []byte(delimiter)) } @@ -204,7 +210,7 @@ func buildOpts(t *testing.T, buf []byte) []Option { lines := bytes.Split(bytes.TrimSpace(buf), []byte{'\n'}) var opts []Option - for i := 0; i < len(lines); i++ { + for i := range lines { line := bytes.TrimSpace(lines[i]) // If the line is empty, keep going if len(line) == 0 { @@ -378,6 +384,8 @@ func parseBool(t *testing.T, buf []byte) bool { return false } +var errInvalidTestData = errors.New("test data is invalid") + func readTestdata(name string) ([]byte, error) { switch name { case "/home/ken/.inputrc", "\\home\\ken\\_inputrc": @@ -391,9 +399,12 @@ func readTestdata(name string) ([]byte, error) { return nil, err } + // Correct for Windows line endings, as test files are embedded. + buf = bytes.ReplaceAll(buf, []byte("\r\n"), []byte("\n")) + v := bytes.Split(buf, []byte(delimiter)) if len(v) != 3 { - return nil, fmt.Errorf("test data %s is invalid", name) + return nil, fmt.Errorf("%w: %s", errInvalidTestData, name) } return v[1], nil diff --git a/readline/inputrc/parse.go b/readline/inputrc/parse.go index 4292d7e..f5c0971 100644 --- a/readline/inputrc/parse.go +++ b/readline/inputrc/parse.go @@ -159,7 +159,8 @@ func (p *Parser) readNext(seq []rune, pos, end int) (string, string, token, erro // does not bind a key) if a space follows the key declaration. made a // decision to instead return an error if the : is missing in all cases. // seek : - for ; pos < end && seq[pos] != ':'; pos++ { + for pos < end && seq[pos] != ':' { + pos++ } if pos == end || seq[pos] != ':' { @@ -486,8 +487,10 @@ func (tok token) String() string { // findNonSpace finds first non space rune in r, returning end if not found. func findNonSpace(r []rune, i, end int) int { - for ; i < end && unicode.IsSpace(r[i]); i++ { + for i < end && unicode.IsSpace(r[i]) { + i++ } + return i } @@ -507,11 +510,11 @@ func findStringEnd(seq []rune, pos, end int) (int, bool) { quote := seq[pos] for pos++; pos < end; pos++ { - switch char = seq[pos]; { - case char == '\\': + switch char = seq[pos]; char { + case '\\': pos++ continue - case char == quote: + case quote: return pos + 1, true } } @@ -618,6 +621,8 @@ func decodeRunes(r []rune, i, end int) string { */ // unescapeRunes decodes escaped string sequence. +// +//nolint:gocognit,gocyclo,cyclop,funlen // Escape-decoding state machine: intentionally dense, splitting it would obscure the cases. func unescapeRunes(r []rune, i, end int) string { var seq []rune var char0, char1, char2, char3, char4, char5 rune diff --git a/readline/internal/color/color.go b/readline/internal/color/color.go index be344aa..de3bdfc 100644 --- a/readline/internal/color/color.go +++ b/readline/internal/color/color.go @@ -89,6 +89,14 @@ func Fmt(color string) string { // string, including all escape codes found between and immediately around // those characters (including surrounding 1st and 80th ones). func Trim(input string, maxPrintableLength int) string { + // A non-positive budget cannot be honoured by the slicing below (it would + // panic on input[:negative]); callers reach this on very narrow terminals + // where the available column width is smaller than the trailing padding. + // Treat it as "nothing fits" and return empty rather than crashing. + if maxPrintableLength <= 0 { + return "" + } + if len(input) < maxPrintableLength { return input } @@ -201,5 +209,13 @@ var re = regexp.MustCompile(ansi) // Strip removes all ANSI escaped color sequences in a string. func Strip(str string) string { + // Fast path: every match in the ansi pattern must begin with an ESC (0x1B) + // or CSI (0x9B) introducer, so a string containing neither has nothing to + // strip. This avoids a regex pass and an allocation for the common case of + // plain (uncolored) completion values, measured on every candidate. + if strings.IndexByte(str, 0x1B) == -1 && strings.IndexByte(str, 0x9B) == -1 { + return str + } + return re.ReplaceAllString(str, "") } diff --git a/readline/internal/color/color_test.go b/readline/internal/color/color_test.go new file mode 100644 index 0000000..3a3bcbb --- /dev/null +++ b/readline/internal/color/color_test.go @@ -0,0 +1,51 @@ +package color + +import "testing" + +func TestStrip(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain", "plain value", "plain value"}, + {"empty", "", ""}, + {"utf8 no escape", "cafÊ — rÊsumÊ", "cafÊ — rÊsumÊ"}, + {"esc colored", "\x1b[31mred\x1b[0m", "red"}, + {"esc surrounding", "a\x1b[1mb\x1b[0mc", "abc"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := Strip(c.in); got != c.want { + t.Fatalf("Strip(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestTrim(t *testing.T) { + cases := []struct { + name string + in string + max int + want string + }{ + {"shorter than max", "abc", 10, "abc"}, + {"exact trim", "abcdef", 3, "abc"}, + {"zero budget", "abcdef", 0, ""}, + // A negative budget is reachable from completion column math on a very + // narrow terminal (maxDisplayWidth - trailingValueLen < 0). It must not + // panic on input[:negative]. + {"negative budget", "abcdef", -4, ""}, + {"trim keeps escapes", "\x1b[31mabcdef\x1b[0m", 3, "\x1b[31mabc"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := Trim(c.in, c.max); got != c.want { + t.Fatalf("Trim(%q, %d) = %q, want %q", c.in, c.max, got, c.want) + } + }) + } +} diff --git a/readline/internal/completion/completion.go b/readline/internal/completion/completion.go index dd46657..0345cb5 100644 --- a/readline/internal/completion/completion.go +++ b/readline/internal/completion/completion.go @@ -13,11 +13,6 @@ type Candidate struct { Style string // An arbitrary string of color/text effects to use when displaying the completion. Tag string // All completions with the same tag are grouped together and displayed under the tag heading. - // A list of runes that are automatically trimmed when a space or a non-nil character is - // inserted immediately after the completion. This is used for slash-autoremoval in path - // completions, comma-separated completions, etc. - noSpace SuffixMatcher - displayLen int // Real length of the displayed candidate, that is not counting escaped sequences. descLen int } diff --git a/readline/internal/completion/display.go b/readline/internal/completion/display.go index d94ab09..bbded34 100644 --- a/readline/internal/completion/display.go +++ b/readline/internal/completion/display.go @@ -3,7 +3,6 @@ package completion import ( "bufio" "fmt" - "regexp" "strings" "github.com/chainreactors/tui/readline/internal/color" @@ -21,22 +20,25 @@ func Display(eng *Engine, maxRows int) { // little more time. The engine itself is responsible for // deleting those lists when it deems them useless. if eng.Matches() == 0 || eng.skipDisplay { - term.Print(term.ClearLineAfter) + term.WriteString(term.ClearLineAfter) return } // The final completions string to print. completions := term.ClearLineAfter + var completionsSb31 strings.Builder for _, group := range eng.groups { - completions += eng.renderCompletions(group) + completionsSb31.WriteString(eng.renderCompletions(group)) } + completions += completionsSb31.String() + // Crop the completions so that it fits within our terminal completions, eng.usedY = eng.cropCompletions(completions, maxRows) if completions != "" { - term.Print(completions) + term.WriteString(completions) } } @@ -125,12 +127,13 @@ func (e *Engine) highlightDisplay(grp *group, val Candidate, pad, col int, selec candidate += color.Reset } } else { - // Highlight the prefix if any and configured for it. - if e.config.GetBool("colored-completion-prefix") && e.prefix != "" { - if prefixMatch, err := regexp.Compile("^" + e.prefix); err == nil { - prefixColored := color.Bold + color.FgBlue + e.prefix + color.BoldReset + color.FgDefault + style - candidate = prefixMatch.ReplaceAllString(candidate, prefixColored) - } + // Highlight the prefix if any and configured for it. The previous + // regexp ("^"+prefix) was compiled once per candidate per render and + // mismatched prefixes containing regex metacharacters; an anchored + // literal prefix is just a HasPrefix check plus a slice. + if e.config.GetBool("colored-completion-prefix") && e.prefix != "" && strings.HasPrefix(candidate, e.prefix) { + prefixColored := color.Bold + color.FgBlue + e.prefix + color.BoldReset + color.FgDefault + style + candidate = prefixColored + candidate[len(e.prefix):] } candidate = style + candidate + color.Reset @@ -197,16 +200,21 @@ func (e *Engine) cutCompletionsBelow(scanner *bufio.Scanner, maxRows int) (strin var count int var cropped string + var croppedSb200 strings.Builder + for scanner.Scan() { line := scanner.Text() if count < maxRows-1 { - cropped += line + term.NewlineReturn + croppedSb200.WriteString(line + term.NewlineReturn) + count++ } else { break } } + cropped += croppedSb200.String() + cropped = strings.TrimSuffix(cropped, term.NewlineReturn) // Add hint for remaining completions, if any. @@ -228,6 +236,8 @@ func (e *Engine) cutCompletionsAboveBelow(scanner *bufio.Scanner, maxRows, absPo var cropped string var count int + var croppedSb231 strings.Builder + for scanner.Scan() { line := scanner.Text() @@ -238,13 +248,16 @@ func (e *Engine) cutCompletionsAboveBelow(scanner *bufio.Scanner, maxRows, absPo } if count > cutAbove && count <= absPos { - cropped += line + term.NewlineReturn + croppedSb231.WriteString(line + term.NewlineReturn) + count++ } else { break } } + cropped += croppedSb231.String() + cropped = strings.TrimSuffix(cropped, term.NewlineReturn) count -= cutAbove + 1 diff --git a/readline/internal/completion/engine.go b/readline/internal/completion/engine.go index 64f365f..dffacb4 100644 --- a/readline/internal/completion/engine.go +++ b/readline/internal/completion/engine.go @@ -2,8 +2,10 @@ package completion import ( "regexp" + "sync/atomic" "github.com/chainreactors/tui/readline/inputrc" + "github.com/chainreactors/tui/readline/internal/color" "github.com/chainreactors/tui/readline/internal/core" "github.com/chainreactors/tui/readline/internal/keymap" "github.com/chainreactors/tui/readline/internal/ui" @@ -37,6 +39,7 @@ type Engine struct { auto bool // Is the engine autocompleting ? autoForce bool // Special autocompletion mode (isearch-style) skipDisplay bool // Don't display completions if there are some. + regenReq int32 // Atomic: an async regeneration of the menu was requested. // Incremental search IsearchRegex *regexp.Regexp // Holds the current search regex match @@ -112,6 +115,121 @@ func (e *Engine) GenerateCached() { e.GenerateWith(e.cached) } +// RequestRegen marks that the active completion menu should be regenerated from +// the cached completer on the next refresh. It is safe to call from any +// goroutine; the regeneration itself runs on the main loop (see ApplyRegen), +// preserving the single-writer rendering invariant. +func (e *Engine) RequestRegen() { + atomic.StoreInt32(&e.regenReq, 1) +} + +// ApplyRegen regenerates the menu from the cached completer if a regeneration +// was requested (RequestRegen) and a menu is currently active. It runs on the +// main loop before a refresh. If a candidate was selected, the same candidate +// is re-selected in the rebuilt menu when it still exists (see +// regenPreservingSelection); otherwise the menu is left with no selection. +func (e *Engine) ApplyRegen() { + if atomic.SwapInt32(&e.regenReq, 0) == 0 { + return + } + + if !e.IsActive() { + return + } + + e.regenPreservingSelection() +} + +// regenPreservingSelection rebuilds the completion menu from the cached +// completer in response to an asynchronous refresh, keeping the user's current +// selection if the same candidate is still present in the new results. +// +// Selection is restored by CONTENT (tag+value), not position: prepare() rebuilds +// and re-sorts the groups, so cell coordinates and group pointers do not survive +// an async update — only the candidate's identity does. +func (e *Engine) regenPreservingSelection() { + // Use the cached completer (explicit menu) or, failing that, the autocomplete + // completer (as-you-type menus, which never set e.cached). Without this + // fallback an autocomplete-driven menu — the common case in richer frontends — + // could not be refreshed while a candidate is selected. + completer := e.cached + if completer == nil { + completer = e.autoCompleter + } + + if completer == nil { + return + } + + // Incremental search maintains its own match/selection lifecycle; don't + // interfere with it, just recompute as before. + if e.keymap.Local() == keymap.Isearch { + e.GenerateCached() + return + } + + // Remember the selected candidate's identity, then drop the virtual + // insertion so the completer regenerates against the REAL line. Otherwise + // the inserted candidate skews the completion context and the menu cannot + // update (it would appear frozen for as long as a candidate is selected). + selTag, selValue := e.selected.Tag, e.selected.Value + hadSelection := selValue != "" + + if hadSelection { + e.cancelCompletedLine() + } + + // Rebuild the candidate pool. Unlike Generate(), this deliberately does NOT + // auto-accept a transient unique candidate: during async streaming a + // momentary single result must not commit the line and close the menu. + e.prepare(completer()) + + if e.noCompletions() { + e.ClearMenu(true) + return + } + + // Restore the selection by re-selecting the same candidate, if it survived. + // If it is gone, the menu is left active with no virtual selection. + if hadSelection && e.selectCandidate(selTag, selValue) { + e.insertCandidate() + } +} + +// selectCandidate positions the menu selector on the (first) candidate matching +// the given tag and value, making its group the current one. Values are matched +// with escape sequences stripped, since the stored selection is stripped too. +// It returns false if no such candidate exists in the current groups. +func (e *Engine) selectCandidate(tag, value string) bool { + want := color.Strip(value) + + for _, grp := range e.groups { + if tag != "" && grp.tag != tag { + continue + } + + for y, row := range grp.rows { + for x, cand := range row { + if color.Strip(cand.Value) != want { + continue + } + + for _, g := range e.groups { + g.isCurrent = false + } + + grp.isCurrent = true + grp.posX = x + grp.posY = y + + return true + } + } + } + + return false +} + // SkipDisplay avoids printing completions below the // input line, but still enables cycling through them. func (e *Engine) SkipDisplay() { @@ -288,6 +406,16 @@ func (e *Engine) Matches() int { return comps } +// DisplaySkipped reports whether completion display is suppressed. +func (e *Engine) DisplaySkipped() bool { + return e.skipDisplay +} + +// ResetUsedRows clears the cached displayed row count. +func (e *Engine) ResetUsedRows() { + e.usedY = 0 +} + // Line returns the relevant input line at the time this function is called: // if a candidate is currently selected, the line returned is the one containing // the candidate. If no candidate is selected, the normal input line is returned. diff --git a/readline/internal/completion/group.go b/readline/internal/completion/group.go index c4b51a4..748dd8a 100644 --- a/readline/internal/completion/group.go +++ b/readline/internal/completion/group.go @@ -1,9 +1,8 @@ package completion import ( - "golang.org/x/exp/slices" "math" - "sort" + "slices" "strconv" "strings" @@ -54,7 +53,7 @@ func (e *Engine) newCompletionGroup(comps Values, tag string, vals RawValues, de // Global actions to take on all values. if !grp.noSort { - sort.Stable(vals) + vals.sortStable() } // Initial processing of our assigned values: @@ -124,9 +123,8 @@ func (g *group) initCompletionsGrid(comps RawValues) { } pairLength := g.longestValueDescribed(comps) - if pairLength > g.termWidth { - pairLength = g.termWidth - } + pairLength = min(g.termWidth, pairLength) + maxColumns := g.termWidth / pairLength if g.list || maxColumns < 0 { maxColumns = 1 @@ -156,7 +154,6 @@ func (g *group) initCompletionAliased(domains []Candidate) { func (g *group) createDescribedRows(values []Candidate) ([][]Candidate, []string) { descriptionMap := make(map[string][]Candidate) uniqueDescriptions := make([]string, 0) - rows := make([][]Candidate, 0) // Separate duplicates and store them. for i, description := range values { @@ -169,6 +166,8 @@ func (g *group) createDescribedRows(values []Candidate) ([][]Candidate, []string } // Sorting helps with easier grids. + rows := make([][]Candidate, 0, len(uniqueDescriptions)) + for _, description := range uniqueDescriptions { row := descriptionMap[description] rows = append(rows, row) @@ -353,9 +352,8 @@ func (g *group) trimDisplay(comp Candidate, pad, col int) (candidate, padded str // Get the allowed length for this column. // The display can never be longer than terminal width. maxDisplayWidth := g.columnsWidth[col] + 1 - if maxDisplayWidth > g.termWidth { - maxDisplayWidth = g.termWidth - } + maxDisplayWidth = min(g.termWidth, maxDisplayWidth) + val = sanitizer.Replace(val) if comp.displayLen > maxDisplayWidth { @@ -412,10 +410,7 @@ func (g *group) getPad(value Candidate, columnIndex int, desc bool) int { // to the terminal size: we are not really sure // of where offsets might be set in the code... column := columns[columnIndex] - if column > g.termWidth-1 { - column = g.termWidth - 1 - } - + column = min(g.termWidth-1, column) padding := column - valLen if padding < 0 { @@ -636,7 +631,7 @@ func createGrid(values []Candidate, rowCount, maxColumns int) [][]Candidate { grid := make([][]Candidate, rowCount) - for i := 0; i < rowCount; i++ { + for i := range rowCount { grid[i] = createRow(values, maxColumns, i) } @@ -646,10 +641,7 @@ func createGrid(values []Candidate, rowCount, maxColumns int) [][]Candidate { func createRow(domains []Candidate, maxColumns, rowIndex int) []Candidate { rowStart := rowIndex * maxColumns rowEnd := (rowIndex + 1) * maxColumns - - if rowEnd > len(domains) { - rowEnd = len(domains) - } + rowEnd = min(len(domains), rowEnd) return domains[rowStart:rowEnd] } diff --git a/readline/internal/completion/insert.go b/readline/internal/completion/insert.go index 547a019..07e7db5 100644 --- a/readline/internal/completion/insert.go +++ b/readline/internal/completion/insert.go @@ -111,6 +111,77 @@ func (e *Engine) refreshLine() { } } +// InsertCommonPrefix extends the input line with the longest prefix shared by +// every current completion candidate, beyond what the user has already typed. +// It implements the insertion half of the GNU readline menu-complete-display-prefix +// option ("display the common prefix of the list of possible completions before +// cycling"); the caller invokes it only when that option is set. When the +// candidates share nothing past the typed prefix it is a no-op, and the menu is +// simply displayed as before. +func (e *Engine) InsertCommonPrefix() { + common := e.commonPrefix() + + // Only extend the typed word; never shorten or re-case it. + if len([]rune(common)) <= len([]rune(e.prefix)) { + return + } + + // Replace the typed prefix with the longer shared one, mirroring how + // acceptCandidate swaps the prefix for a full candidate value. + e.cursor.Move(-1 * len(e.prefix)) + e.line.Cut(e.cursor.Pos(), e.cursor.Pos()+len(e.prefix)) + e.cursor.InsertAt([]rune(common)...) + + // Keep the engine prefix in sync so the menu highlighting and any + // subsequent candidate insertion stay consistent with the new word. + e.prefix = common +} + +// commonPrefix returns the longest string that prefixes every current candidate's +// inserted value, or "" when they share nothing (or there are no candidates). +// The comparison is case-sensitive: candidates differing only in case stop the +// prefix early, a conservative choice that never inserts characters not shared +// verbatim by all candidates. +func (e *Engine) commonPrefix() string { + var ( + common string + seen bool + ) + + for _, grp := range e.groups { + for _, row := range grp.rows { + for _, cand := range row { + if !seen { + common = cand.Value + seen = true + + continue + } + + common = commonStringPrefix(common, cand.Value) + if common == "" { + return "" + } + } + } + } + + return common +} + +// commonStringPrefix returns the longest rune-aligned common prefix of a and b. +func commonStringPrefix(a, b string) string { + ra, rb := []rune(a), []rune(b) + bound := min(len(ra), len(rb)) + + i := 0 + for i < bound && ra[i] == rb[i] { + i++ + } + + return string(ra[:i]) +} + // acceptCandidate inserts the currently selected candidate into the real input line. func (e *Engine) acceptCandidate() { cur := e.currentGroup() diff --git a/readline/internal/completion/insert_test.go b/readline/internal/completion/insert_test.go new file mode 100644 index 0000000..b730fd9 --- /dev/null +++ b/readline/internal/completion/insert_test.go @@ -0,0 +1,108 @@ +package completion + +import ( + "testing" + + "github.com/chainreactors/tui/readline/internal/core" +) + +// newPrefixEngine builds a minimal engine whose menu already holds the given +// candidate values (spread across two groups to exercise multi-group walking), +// with the real line containing the already-typed prefix and the cursor at its +// end -- the state completeWord/menuComplete are in when the display-prefix +// option fires. +func newPrefixEngine(typed string, values ...string) (*Engine, *core.Line) { + l := core.Line([]rune(typed)) + line := &l + cur := core.NewCursor(line) + cur.Set(line.Len()) + + var groups []*group + + for i, v := range values { + // Alternate candidates between two groups so commonPrefix must walk + // more than one group and more than one row. + gi := i % 2 + for len(groups) <= gi { + groups = append(groups, &group{}) + } + + groups[gi].rows = append(groups[gi].rows, []Candidate{{Value: v}}) + } + + e := &Engine{ + line: line, + cursor: cur, + prefix: typed, + groups: groups, + } + + return e, line +} + +func TestCommonStringPrefix(t *testing.T) { + cases := []struct{ a, b, want string }{ + {"foobar", "foobaz", "fooba"}, + {"foo", "foobar", "foo"}, + {"abc", "xyz", ""}, + {"", "foo", ""}, + {"cafÊ", "cafard", "caf"}, // rune-aligned, must not split the 'Ê' + } + + for _, c := range cases { + if got := commonStringPrefix(c.a, c.b); got != c.want { + t.Errorf("commonStringPrefix(%q, %q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} + +func TestInsertCommonPrefix(t *testing.T) { + t.Run("extends to shared prefix", func(t *testing.T) { + e, line := newPrefixEngine("foo", "foobar", "foobaz", "foobat") + e.InsertCommonPrefix() + + if got := string(*line); got != "fooba" { + t.Fatalf("line = %q, want %q", got, "fooba") + } + + if e.prefix != "fooba" { + t.Fatalf("engine prefix = %q, want %q", e.prefix, "fooba") + } + + if e.cursor.Pos() != 5 { + t.Fatalf("cursor = %d, want 5 (end of extended word)", e.cursor.Pos()) + } + }) + + t.Run("no shared prefix beyond typed is a no-op", func(t *testing.T) { + e, line := newPrefixEngine("f", "foo", "fbar") + e.InsertCommonPrefix() + + // "foo" and "fbar" share only "f", which equals the typed prefix. + if got := string(*line); got != "f" { + t.Fatalf("line = %q, want unchanged %q", got, "f") + } + + if e.prefix != "f" { + t.Fatalf("engine prefix = %q, want unchanged %q", e.prefix, "f") + } + }) + + t.Run("single candidate extends fully", func(t *testing.T) { + e, line := newPrefixEngine("re", "readline") + e.InsertCommonPrefix() + + if got := string(*line); got != "readline" { + t.Fatalf("line = %q, want %q", got, "readline") + } + }) + + t.Run("no candidates is a no-op", func(t *testing.T) { + e, line := newPrefixEngine("foo") + e.InsertCommonPrefix() + + if got := string(*line); got != "foo" { + t.Fatalf("line = %q, want unchanged %q", got, "foo") + } + }) +} diff --git a/readline/internal/completion/message.go b/readline/internal/completion/message.go index ce9a26d..15a19ba 100644 --- a/readline/internal/completion/message.go +++ b/readline/internal/completion/message.go @@ -8,6 +8,8 @@ import ( // Messages is a list of messages to be displayed // below the input line, above completions. It is // used to show usage and/or error status hints. +// +//nolint:recvcheck // IsEmpty/Get read via value receiver; init/Add/Suppress/Merge mutate via pointer (intentional). type Messages struct { messages map[string]bool } @@ -32,7 +34,7 @@ func (m *Messages) Add(s string) { // Get returns the list of messages to display. func (m Messages) Get() []string { - messages := make([]string, 0) + messages := make([]string, 0, len(m.messages)) for message := range m.messages { messages = append(messages, message) } diff --git a/readline/internal/completion/suffix.go b/readline/internal/completion/suffix.go index f0e4944..052fca0 100644 --- a/readline/internal/completion/suffix.go +++ b/readline/internal/completion/suffix.go @@ -6,6 +6,8 @@ import ( ) // SuffixMatcher is a type managing suffixes for a given list of completions. +// +//nolint:recvcheck // Matches reads via value receiver; Add/Merge mutate via pointer (intentional). type SuffixMatcher struct { string pos int // Used to know if the saved suffix matcher is deprecated diff --git a/readline/internal/completion/syntax.go b/readline/internal/completion/syntax.go index d0f9c58..4fbbb4f 100644 --- a/readline/internal/completion/syntax.go +++ b/readline/internal/completion/syntax.go @@ -32,34 +32,32 @@ func AutopairInsertOrJump(key rune, line *core.Line, cur *core.Cursor) (skipInse matcher, closer := strutil.SurroundType(key) if !matcher { - return + return false } if closer && cur.Char() == key { - skipInsert = true - cur.Inc() - return + return true } // If we are currently inside a quoted string, we don't want to insert pairs. // This also effectively allows closing the quote we are currently in. if key == '"' || key == '\'' { if unclosed, _ := strutil.GetQuotedWordStart((*line)[:cur.Pos()]); unclosed { - return + return false } } switch { case closer && key != '\'' && key != '"': - return + return false default: _, closeChar := strutil.MatchSurround(key) line.Insert(cur.Pos(), closeChar) } - return + return false } // AutopairDelete checks if the character under the cursor is an opening pair diff --git a/readline/internal/completion/syntax_test.go b/readline/internal/completion/syntax_test.go new file mode 100644 index 0000000..1a02220 --- /dev/null +++ b/readline/internal/completion/syntax_test.go @@ -0,0 +1,87 @@ +package completion + +import ( + "testing" + + "github.com/chainreactors/tui/readline/internal/core" +) + +func TestAutopairInsertOrJump(t *testing.T) { + tests := []struct { + name string + line string + cursor int + key rune + wantLine string + wantSkip bool + wantCursor int // Relative to original if not specified? No, absolute. + }{ + { + name: "Empty line, insert quote", + line: "", + cursor: 0, + key: '"', + wantLine: "\"", // Function inserts closer + wantSkip: false, // selfInsert will insert opener + wantCursor: 0, // Cursor stays same (caller handles insert) + }, + { + name: "Inside quote, type closing quote", + line: "\"foo", + cursor: 4, + key: '"', + wantLine: "\"foo", // Should NOT insert pair + wantSkip: false, // selfInsert will insert '"' -> "foo" + wantCursor: 4, + }, + { + name: "Balanced quotes, type new quote", + line: "\"foo\"", + cursor: 5, + key: '"', + wantLine: "\"foo\"\"", // Inserts closer + wantSkip: false, + wantCursor: 5, + }, + { + name: "Escaped quote inside double, type quote", + line: "\"foo \\\"", + cursor: 7, + key: '"', + wantLine: "\"foo \\\"", // Should detect unclosed and NOT insert pair + wantSkip: false, + wantCursor: 7, + }, + { + name: "Jump over closing quote", + line: "\"foo\"", + cursor: 4, // before last " + key: '"', + wantLine: "\"foo\"", + wantSkip: true, + wantCursor: 5, // Inc + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + line := core.Line([]rune(tt.line)) + cur := core.NewCursor(&line) + cur.Set(tt.cursor) + + skip := AutopairInsertOrJump(tt.key, &line, cur) + + if skip != tt.wantSkip { + t.Errorf("AutopairInsertOrJump() skip = %v, want %v", skip, tt.wantSkip) + } + + if string(line) != tt.wantLine { + t.Errorf("AutopairInsertOrJump() line = %q, want %q", string(line), tt.wantLine) + } + + if cur.Pos() != tt.wantCursor { + t.Errorf("AutopairInsertOrJump() cursor = %v, want %v", cur.Pos(), tt.wantCursor) + } + }) + } +} diff --git a/readline/internal/completion/values.go b/readline/internal/completion/values.go index 5db020e..d5445fb 100644 --- a/readline/internal/completion/values.go +++ b/readline/internal/completion/values.go @@ -1,6 +1,7 @@ package completion import ( + "sort" "strings" ) @@ -98,3 +99,27 @@ func (c RawValues) Swap(i, j int) { c[i], c[j] = c[j], c[i] } func (c RawValues) Less(i, j int) bool { return strings.ToLower(c[i].Value) < strings.ToLower(c[j].Value) } + +// sortStable orders the values case-insensitively by Value, stably. It folds +// each value to lower case exactly once (paired sort) rather than twice per +// comparison as the Less method does, which matters on large candidate sets +// sorted on every keystroke. The ordering is identical to sort.Stable(c). +func (c RawValues) sortStable() { + type keyed struct { + key string + value Candidate + } + + pairs := make([]keyed, len(c)) + for i, val := range c { + pairs[i] = keyed{key: strings.ToLower(val.Value), value: val} + } + + sort.SliceStable(pairs, func(i, j int) bool { + return pairs[i].key < pairs[j].key + }) + + for i := range pairs { + c[i] = pairs[i].value + } +} diff --git a/readline/internal/completion/values_test.go b/readline/internal/completion/values_test.go new file mode 100644 index 0000000..4b8bb25 --- /dev/null +++ b/readline/internal/completion/values_test.go @@ -0,0 +1,84 @@ +package completion + +import ( + "sort" + "testing" +) + +// referenceSort is the original sorting path (sort.Stable + the Less method), +// kept here so we can assert sortStable produces an identical ordering. +func referenceSort(c RawValues) RawValues { + out := make(RawValues, len(c)) + copy(out, c) + sort.Stable(out) + + return out +} + +func TestSortStableMatchesReference(t *testing.T) { + cases := [][]string{ + {"Banana", "apple", "Cherry", "banana", "APPLE", "cherry"}, + {"z", "a", "m", "A", "Z", "M"}, + {"git push", "git pull", "git Push", "GIT", "git"}, + {}, + {"only"}, + } + + for _, values := range cases { + raw := make(RawValues, len(values)) + for i, v := range values { + raw[i] = Candidate{Value: v} + } + + want := referenceSort(raw) + + got := make(RawValues, len(raw)) + copy(got, raw) + got.sortStable() + + for i := range want { + if got[i].Value != want[i].Value { + t.Fatalf("ordering mismatch for %v at %d: got %q, want %q", + values, i, got[i].Value, want[i].Value) + } + } + } +} + +func benchValues(n int) RawValues { + base := make(RawValues, n) + for i := range base { + // Mixed case so the comparator does real case-folding work. + base[i] = Candidate{Value: string(rune('A'+(i%26))) + string(rune('a'+(i*7%26)))} + } + + return base +} + +// BenchmarkSortStable vs BenchmarkReferenceSort: the keyed sort folds each +// value once (~n allocations) instead of twice per comparison (~n*log n). +func BenchmarkSortStable(b *testing.B) { + base := benchValues(1000) + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + work := make(RawValues, len(base)) + copy(work, base) + work.sortStable() + } +} + +func BenchmarkReferenceSort(b *testing.B) { + base := benchValues(1000) + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + work := make(RawValues, len(base)) + copy(work, base) + sort.Stable(work) + } +} diff --git a/readline/internal/core/api_windows.go b/readline/internal/core/api_windows.go index ad8881c..deac0a4 100644 --- a/readline/internal/core/api_windows.go +++ b/readline/internal/core/api_windows.go @@ -9,6 +9,8 @@ import ( "reflect" "syscall" "unsafe" + + "github.com/chainreactors/tui/readline/internal/term" ) var ( @@ -167,6 +169,10 @@ func setConsoleCursorPosition(c *_COORD) error { // GetCursorPos returns the current cursor position on Windows. func (k *Keys) GetCursorPos() (x, y int) { + // Flush any buffered frame output so the console cursor reflects everything + // printed so far before we read its position. + term.FlushBuffer() + t := new(_CONSOLE_SCREEN_BUFFER_INFO) kernel.GetConsoleScreenBufferInfo( stdout, diff --git a/readline/internal/core/cursor.go b/readline/internal/core/cursor.go index 6a6d9f6..77ffbd9 100644 --- a/readline/internal/core/cursor.go +++ b/readline/internal/core/cursor.go @@ -246,15 +246,20 @@ func (c *Cursor) LineMove(lines int) { return } + // Each step lands the cursor at the target column, or at the end of a + // shorter line (on its trailing newline). We deliberately do NOT clamp off + // that newline here: LineMove is keymap-agnostic, and the per-command + // post-step in Shell.execute already applies CheckCommand in vi-command + // mode and CheckAppend otherwise. Clamping here as well double-applied the + // command-mode rule, leaving the cursor one column short of the line end in + // emacs and vi-insert modes. if lines < 0 { - for i := 0; i < -1*lines; i++ { + for range -lines { c.moveLineUp() - c.CheckCommand() } } else { - for i := 0; i < lines; i++ { + for range lines { c.moveLineDown() - c.CheckCommand() } } } @@ -287,7 +292,7 @@ func (c *Cursor) AtBeginningOfLine() bool { newlines := c.line.newlines() - for line := 0; line < len(newlines); line++ { + for line := range newlines { epos := newlines[line][0] if epos == c.pos-1 { return true @@ -306,7 +311,7 @@ func (c *Cursor) AtEndOfLine() bool { newlines := c.line.newlines() - for line := 0; line < len(newlines); line++ { + for line := range newlines { epos := newlines[line][0] if epos == c.pos+1 { return true @@ -394,7 +399,7 @@ func (c *Cursor) moveLineDown() { newlines := c.line.newlines() - for line := 0; line < len(newlines); line++ { + for line := range newlines { end := newlines[line][0] if line < c.LinePos() { begin = end diff --git a/readline/internal/core/cursor_test.go b/readline/internal/core/cursor_test.go index ffc3ddf..0f31744 100644 --- a/readline/internal/core/cursor_test.go +++ b/readline/internal/core/cursor_test.go @@ -762,6 +762,85 @@ func TestCursor_LineMove(t *testing.T) { } } +// TestCursor_LineMove_LandsAtLineEnd is the regression guard for the multiline +// off-by-one: moving onto a shorter (non-empty) line must leave the cursor at +// that line's end (its trailing newline = the append position), not one column +// short of it. LineMove used to apply CheckCommand on every step, which clamped +// the cursor off the newline even for emacs/vi-insert, where end-of-line is a +// valid position. +func TestCursor_LineMove_LandsAtLineEnd(t *testing.T) { + // indices: a0 b1 c2 d3 e4 f5 \n6 x7 \n8 g9 h10 i11 j12 k13 l14 + line := Line("abcdef\nx\nghijkl") + + // On line 0, column 5 ('f'); move down onto the 1-char line "x". + c := &Cursor{line: &line, pos: 5, mark: -1} + c.LineMove(1) + + // End of "x" is the trailing newline at index 8 (append position). + if c.Pos() != 8 { + t.Fatalf("down onto short line: pos %d, want 8 (end of line, not one short)", c.Pos()) + } + + if c.LinePos() != 1 { + t.Fatalf("down onto short line: line %d, want 1", c.LinePos()) + } +} + +// TestCursor_LineMove_ModeClampContract documents the division of labour: +// LineMove leaves the cursor at the end-of-line position, and the caller's +// post-step check decides whether that is allowed. Shell.execute applies +// CheckAppend in emacs/vi-insert (end-of-line kept) and CheckCommand in +// vi-command (clamped onto the last char). +func TestCursor_LineMove_ModeClampContract(t *testing.T) { + line := Line("abcdef\nx\nghijkl") + + // emacs / vi-insert: CheckAppend keeps the end-of-line position. + emacs := &Cursor{line: &line, pos: 5, mark: -1} + emacs.LineMove(1) + emacs.CheckAppend() + + if emacs.Pos() != 8 { + t.Fatalf("emacs post-move: pos %d, want 8 (end of line kept)", emacs.Pos()) + } + + // vi-command: CheckCommand clamps off the newline onto 'x'. + vicmd := &Cursor{line: &line, pos: 5, mark: -1} + vicmd.LineMove(1) + vicmd.CheckCommand() + + if vicmd.Pos() != 7 { + t.Fatalf("vi-command post-move: pos %d, want 7 (clamped onto last char)", vicmd.Pos()) + } +} + +// TestCursor_LineMove_StickyColumn checks that vertical motion across lines that +// are all long enough preserves the column, and a down-then-up round trip +// returns to the starting position. +func TestCursor_LineMove_StickyColumn(t *testing.T) { + // Three equal-length lines: a0..f5 \n6 g7..l12 \n13 m14..r19 + line := Line("abcdef\nghijkl\nmnopqr") + + c := &Cursor{line: &line, pos: 3, mark: -1} // line 0, column 3 ('d') + + c.LineMove(1) + + if c.Pos() != 10 { // line 1, column 3 ('j') + t.Fatalf("down one: pos %d, want 10", c.Pos()) + } + + c.LineMove(1) + + if c.Pos() != 17 { // line 2, column 3 ('p') + t.Fatalf("down two: pos %d, want 17", c.Pos()) + } + + c.LineMove(-2) + + if c.Pos() != 3 { // back to start + t.Fatalf("round trip up: pos %d, want 3", c.Pos()) + } +} + func TestCursor_OnEmptyLine(t *testing.T) { type fields struct { pos int diff --git a/readline/internal/core/iterations.go b/readline/internal/core/iterations.go index 5255939..3d6b496 100644 --- a/readline/internal/core/iterations.go +++ b/readline/internal/core/iterations.go @@ -48,11 +48,12 @@ func (i *Iterations) Get() int { times, err := strconv.Atoi(i.times) // Any invalid value is still one time. - if err != nil && strings.HasPrefix(i.times, "-") { + switch { + case err != nil && strings.HasPrefix(i.times, "-"): times = -1 - } else if err != nil && times == 0 { + case err != nil && times == 0: times = 1 - } else if times == 0 && strings.HasPrefix(i.times, "-") { + case times == 0 && strings.HasPrefix(i.times, "-"): times = -1 } diff --git a/readline/internal/core/keys.go b/readline/internal/core/keys.go index adacce5..b2437a8 100644 --- a/readline/internal/core/keys.go +++ b/readline/internal/core/keys.go @@ -8,9 +8,10 @@ import ( "regexp" "sync" + "github.com/rivo/uniseg" + "github.com/chainreactors/tui/readline/inputrc" "github.com/chainreactors/tui/readline/internal/strutil" - "github.com/rivo/uniseg" ) const ( @@ -24,6 +25,10 @@ var Stdin io.ReadCloser = os.Stdin var rxRcvCursorPos = regexp.MustCompile(`\x1b\[([0-9]+);([0-9]+)R`) +// errInputWake is returned by the input read when it was interrupted by an +// async refresh request (RequestRefresh) rather than by actual key input. +var errInputWake = errors.New("readline: input wake") + // Keys is used to read, manage and use keys input by the shell user. type Keys struct { input io.Reader @@ -35,17 +40,24 @@ type Keys struct { reading bool // Currently reading keys out of the main loop. keysOnce chan []byte // Passing keys from the main routine. cursor chan []byte // Cursor coordinates has been read on stdin. - resize chan bool // Resize events on Windows are sent on stdin. USED IN WINDOWS + resize chan bool //nolint:unused // Resize events on Windows are sent on stdin; consumed only by the windows build. + + wakeMu sync.Mutex // Guards the wake fields against RequestRefresh (other goroutine). + wakeR int // Read end of the async-refresh wake pipe. + wakeW int // Write end of the async-refresh wake pipe. + wakeReady bool // Whether the wake pipe is initialized and usable. - eof bool // EOF has been reached. - cfg *inputrc.Config // Configuration file used for meta key settings - mutex sync.RWMutex // Concurrency safety + eof bool // EOF has been reached. + readErr error // First non-EOF, non-wake input failure (e.g. a revoked tty). + cfg *inputrc.Config // Configuration file used for meta key settings + mutex sync.RWMutex // Concurrency safety } // SetInput sets the reader used for keyboard input. func (k *Keys) SetInput(input io.Reader) { k.mutex.Lock() defer k.mutex.Unlock() + k.input = input } @@ -53,9 +65,11 @@ func (k *Keys) inputReader() io.Reader { k.mutex.RLock() input := k.input k.mutex.RUnlock() + if input != nil { return input } + return Stdin } @@ -89,8 +103,26 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) { // We will either read keyBuf from user, or an EOF // send by ourselves, because we pause reading. keyBuf, err := keys.readInputFiltered() - if err != nil && errors.Is(err, io.EOF) { - keys.eof = true + if err != nil { + // Wake: an async refresh was requested (RequestRefresh) — return + // with no keys so the main loop repaints the (possibly updated) UI + // and waits again. EOF: input stream closed. Any other error (e.g. + // a revoked tty) is a real read failure: record it so the main loop + // can surface it and exit, instead of spinning on it forever. + switch { + case errors.Is(err, errInputWake): + case errors.Is(err, io.EOF): + keys.mutex.Lock() + keys.eof = true + keys.mutex.Unlock() + default: + keys.mutex.Lock() + if keys.readErr == nil { + keys.readErr = err + } + keys.mutex.Unlock() + } + return } @@ -110,22 +142,51 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) { keyBuf = []byte(strutil.ConvertMeta([]rune(string(keyBuf)))) } - keys.mutex.Lock() + keys.mutex.RLock() keys.buf = append(keys.buf, keyBuf...) - keys.mutex.Unlock() + keys.mutex.RUnlock() } return } } +// Empty reports whether there are no keys available to dispatch: neither +// buffered input keys nor macro-fed keys. The main loop uses this to detect a +// bare async-refresh wake (which returns from WaitAvailableKeys with no keys). +func (k *Keys) Empty() bool { + k.mutex.RLock() + defer k.mutex.RUnlock() + + return len(k.buf) == 0 && len(k.macroKeys) == 0 +} + +// IsEOF returns true if the input stream has reached the end. +func (k *Keys) IsEOF() bool { + k.mutex.RLock() + defer k.mutex.RUnlock() + + return k.eof +} + +// ReadError returns the first non-EOF input failure observed while reading keys +// (for instance a revoked tty), or nil. Async-refresh wakes are not errors and +// are never reported here. The main loop uses this to exit cleanly instead of +// spinning on an unrecoverable input stream. +func (k *Keys) ReadError() error { + k.mutex.RLock() + defer k.mutex.RUnlock() + + return k.readErr +} + // PeekKey returns the first key in the stack, without removing it. func PeekKey(keys *Keys) (key byte, empty bool) { switch { case len(keys.buf) > 0: key = keys.buf[0] case len(keys.macroKeys) > 0: - key = byte(keys.macroKeys[0]) + key = byte(keys.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional. default: return byte(0), true } @@ -141,7 +202,7 @@ func PopKey(keys *Keys) (key byte, empty bool) { key = keys.buf[0] keys.buf = keys.buf[1:] case len(keys.macroKeys) > 0: - key = byte(keys.macroKeys[0]) + key = byte(keys.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional. keys.macroKeys = keys.macroKeys[1:] default: return byte(0), true @@ -229,7 +290,7 @@ func PopForce(keys *Keys) (key byte, empty bool) { key = keys.buf[0] keys.buf = keys.buf[1:] case len(keys.macroKeys) > 0: - key = byte(keys.macroKeys[0]) + key = byte(keys.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional. keys.macroKeys = keys.macroKeys[1:] default: return byte(0), true @@ -259,9 +320,14 @@ func FlushUsed(keys *Keys) { defer keys.mutex.Unlock() } +// Read drains the currently buffered pending keys. func (k *Keys) Read() []byte { + k.mutex.Lock() + defer k.mutex.Unlock() + buf := k.buf - k.buf = []byte{} + k.buf = nil + return buf } @@ -269,15 +335,15 @@ func (k *Keys) Read() []byte { // returns them instead of storing them in the stack, along with // an indication on whether this key is an escape/abort one. func (k *Keys) ReadKey() (key rune, isAbort bool) { - k.mutex.Lock() + k.mutex.RLock() k.keysOnce = make(chan []byte) k.reading = true - k.mutex.Unlock() + k.mutex.RUnlock() defer func() { - k.mutex.Lock() + k.mutex.RLock() k.reading = false - k.mutex.Unlock() + k.mutex.RUnlock() }() switch { @@ -287,9 +353,28 @@ func (k *Keys) ReadKey() (key rune, isAbort bool) { case k.waiting: buf := <-k.keysOnce + if len(buf) == 0 { + return rune(0), true + } + key = []rune(string(buf))[0] default: - buf, _ := k.readInputFiltered() + // Read until we get an actual key: ignore async-refresh wakes + // (errInputWake) and empty reads, which carry no key to return. + var buf []byte + for len(buf) == 0 { + b, err := k.readInputFiltered() + if err != nil && !errors.Is(err, errInputWake) { + break + } + + buf = b + } + + if len(buf) == 0 { + return rune(0), true + } + key = []rune(string(buf))[0] } @@ -315,7 +400,7 @@ func (k *Keys) Pop() (key byte, empty bool) { key = k.buf[0] k.buf = k.buf[1:] case len(k.macroKeys) > 0: - key = byte(k.macroKeys[0]) + key = byte(k.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional. k.macroKeys = k.macroKeys[1:] default: return byte(0), true @@ -351,16 +436,19 @@ func (k *Keys) Feed(begin bool, keys ...rune) { } } -// IsReading returns true if the keys system is currently reading input +// IsReading returns true if the keys system is currently reading input. func (k *Keys) IsReading() bool { k.mutex.RLock() defer k.mutex.RUnlock() + return k.reading } +// IsWaiting returns true if the keys system is currently waiting for input. func (k *Keys) IsWaiting() bool { k.mutex.RLock() defer k.mutex.RUnlock() + return k.waiting } @@ -368,59 +456,63 @@ func (k *Keys) IsWaiting() bool { func (k *Keys) HasPendingInput() bool { k.mutex.RLock() defer k.mutex.RUnlock() - return len(k.buf) > 0 -} -// IsEOF returns true if the input stream has reached the end. -func (k *Keys) IsEOF() bool { - k.mutex.RLock() - defer k.mutex.RUnlock() - return k.eof + return len(k.buf) > 0 } -// ReadUntilSequence reads bytes from the key buffer and stdin until the -// end sequence is found. Returns all content before the end sequence. -// The end sequence itself is consumed and discarded. -// Used by bracketed paste to read until \e[201~. -const maxPasteSize = 1 << 20 // 1MB safety limit - +// ReadUntilSequence reads bytes from the key buffer and input reader until the +// end sequence is found. The end sequence itself is consumed and discarded. func (k *Keys) ReadUntilSequence(end []byte) []byte { + if len(end) == 0 { + return nil + } + var result []byte for { - // Check if the end sequence is in the current buffer + k.mutex.Lock() + if len(k.macroKeys) > 0 { + k.buf = append(k.buf, []byte(string(k.macroKeys))...) + k.macroKeys = nil + } if idx := bytes.Index(k.buf, end); idx >= 0 { result = append(result, k.buf[:idx]...) k.buf = k.buf[idx+len(end):] + k.mutex.Unlock() return result } - // Safety limit to prevent infinite reads if len(result)+len(k.buf) > maxPasteSize { result = append(result, k.buf...) k.buf = nil + k.mutex.Unlock() return result } - // Keep buffer tail that might contain partial end sequence match safe := len(k.buf) - len(end) + 1 if safe > 0 { result = append(result, k.buf[:safe]...) k.buf = k.buf[safe:] } + k.mutex.Unlock() - // Read more from stdin newKeys, err := k.readInputFiltered() if err != nil { + k.mutex.Lock() result = append(result, k.buf...) k.buf = nil + k.mutex.Unlock() return result } + k.mutex.Lock() k.buf = append(k.buf, newKeys...) + k.mutex.Unlock() } } +const maxPasteSize = 1 << 20 + func (k *Keys) extractCursorPos(keys []byte) (cursor, remain []byte) { if !rxRcvCursorPos.Match(keys) { return cursor, keys diff --git a/readline/internal/core/keys_test.go b/readline/internal/core/keys_test.go new file mode 100644 index 0000000..83cb9af --- /dev/null +++ b/readline/internal/core/keys_test.go @@ -0,0 +1,124 @@ +package core + +import ( + "errors" + "io" + "testing" + + "github.com/chainreactors/tui/readline/inputrc" +) + +// errReadFailed is a static stand-in for a non-EOF input failure in tests. +var errReadFailed = errors.New("read failed") + +// readStep is one scripted result of a stub stdin Read. +type readStep struct { + data []byte + err error +} + +// stubReadCloser plays back a fixed sequence of Read results, then EOF. It is +// not an *os.File, so the Unix key reader's poll path is skipped and reads fall +// straight through to this stub. +type stubReadCloser struct { + steps []readStep + index int +} + +func (s *stubReadCloser) Read(p []byte) (int, error) { + if s.index >= len(s.steps) { + return 0, io.EOF + } + + step := s.steps[s.index] + s.index++ + + n := copy(p, step.data) + + return n, step.err +} + +func (s *stubReadCloser) Close() error { return nil } + +// withStubStdin swaps the package stdin for the duration of a test. +func withStubStdin(t *testing.T, steps ...readStep) { + t.Helper() + + original := Stdin + Stdin = &stubReadCloser{steps: steps} + + t.Cleanup(func() { Stdin = original }) +} + +// TestWaitAvailableKeysMarksEOF verifies a closed stream is recorded as EOF and +// is not mistaken for a read error. +func TestWaitAvailableKeysMarksEOF(t *testing.T) { + withStubStdin(t, readStep{err: io.EOF}) + + keys := &Keys{} + WaitAvailableKeys(keys, &inputrc.Config{}) + + if !keys.IsEOF() { + t.Fatal("expected EOF to be recorded") + } + + if err := keys.ReadError(); err != nil { + t.Fatalf("expected no read error on EOF, got %v", err) + } +} + +// TestWaitAvailableKeysRecordsNonEOFReadError verifies a non-EOF failure (e.g. a +// revoked tty) is surfaced via ReadError instead of being swallowed, which is +// what previously left the input loop spinning. +func TestWaitAvailableKeysRecordsNonEOFReadError(t *testing.T) { + want := errReadFailed + + withStubStdin(t, readStep{err: want}) + + keys := &Keys{} + WaitAvailableKeys(keys, &inputrc.Config{}) + + if keys.IsEOF() { + t.Fatal("non-EOF failure must not be marked as EOF") + } + + if err := keys.ReadError(); !errors.Is(err, want) { + t.Fatalf("expected read error %v, got %v", want, err) + } +} + +// TestReadKeyReturnsAbortOnEOF verifies ReadKey aborts cleanly on EOF rather +// than indexing into an empty buffer and panicking (Vim f/F/t/T motions). +func TestReadKeyReturnsAbortOnEOF(t *testing.T) { + withStubStdin(t, readStep{err: io.EOF}) + + keys := &Keys{} + + key, isAbort := keys.ReadKey() + + if !isAbort { + t.Fatal("expected ReadKey to abort on EOF") + } + + if key != 0 { + t.Fatalf("expected zero key on EOF, got %q", key) + } +} + +// TestReadKeySkipsEmptyReads verifies ReadKey keeps waiting past an empty read +// and returns the next real key. +func TestReadKeySkipsEmptyReads(t *testing.T) { + withStubStdin(t, readStep{}, readStep{data: []byte("x")}) + + keys := &Keys{} + + key, isAbort := keys.ReadKey() + + if isAbort { + t.Fatal("expected ReadKey to continue after an empty read") + } + + if key != 'x' { + t.Fatalf("expected key %q, got %q", 'x', key) + } +} diff --git a/readline/internal/core/keys_unix.go b/readline/internal/core/keys_unix.go index 2673fe8..3802e26 100644 --- a/readline/internal/core/keys_unix.go +++ b/readline/internal/core/keys_unix.go @@ -3,45 +3,45 @@ package core import ( - "bytes" "errors" "io" "os" - "os/signal" "strconv" - "syscall" - "time" - "github.com/chainreactors/tui/readline/internal/term" "golang.org/x/sys/unix" -) - -const cursorPosTimeout = 200 * time.Millisecond -var errCursorPosTimeout = errors.New("cursor position read timeout") + "github.com/chainreactors/tui/readline/internal/term" +) // GetCursorPos returns the current cursor position in the terminal. // It is safe to call this function even if the shell is reading input. func (k *Keys) GetCursorPos() (x, y int) { - disable := func() (int, int) { return -1, -1 } + reader := k.inputReader() + if control := term.CurrentControl(); control != nil { + if !control.IsTerminal() { + return -1, -1 + } + } else if fd, ok := readerFd(reader); !ok || !term.IsTerminal(fd) { + return -1, -1 + } + + disable := func() (int, int) { + os.Stderr.WriteString("\r\ngetCursorPos() not supported by terminal emulator, disabling....\r\n") + return -1, -1 + } var cursor []byte - var pending []byte + var match [][]string - deadline := time.Now().Add(cursorPosTimeout) + // Flush any buffered frame output first: the cursor position we are about + // to query is only correct once the prompt printed so far is actually on + // screen, not still sitting in the output buffer. + term.FlushBuffer() -drain: - for { - select { - case <-k.cursor: - default: - break drain - } - } + // Echo the query and wait for the main key reading routine to send us the + // response back. + term.WriteString("\x1b[6n") - // Echo the query and wait for the main key - // reading routine to send us the response back. - term.Print("\x1b[6n") // In order not to get stuck with an input that might be user-one // (like when the user typed before the shell is fully started, and yet not having // queried cursor yet), we keep reading from stdin until we find the cursor response. @@ -49,108 +49,65 @@ drain: for { switch { case k.waiting, k.reading: - remaining := time.Until(deadline) - if remaining <= 0 { - return -1, -1 - } - - select { - case cursor = <-k.cursor: - case <-time.After(remaining): - return -1, -1 - } - - indices := rxRcvCursorPos.FindSubmatchIndex(cursor) - if indices == nil { - k.mutex.Lock() - k.buf = append(k.buf, cursor...) - k.mutex.Unlock() - continue - } + cursor = <-k.cursor + default: + buf := make([]byte, keyScanBufSize) - y, err := strconv.Atoi(string(cursor[indices[2]:indices[3]])) + read, err := reader.Read(buf) if err != nil { return disable() } - x, err = strconv.Atoi(string(cursor[indices[4]:indices[5]])) - if err != nil { - return disable() - } + cursor = buf[:read] + } - return x, y + // We have read (or have been passed) something. + if len(cursor) == 0 { + return disable() + } - default: - remaining := time.Until(deadline) - if remaining <= 0 { - return -1, -1 - } + // Attempt to locate cursor response in it. + match = rxRcvCursorPos.FindAllStringSubmatch(string(cursor), 1) - buf := make([]byte, keyScanBufSize) + // If there is something but not cursor answer, its user input. + if len(match) == 0 && len(cursor) > 0 { + k.mutex.RLock() + k.buf = append(k.buf, cursor...) + k.mutex.RUnlock() - read, err := readInputWithTimeout(k.inputReader(), buf, remaining) - if err != nil { - if errors.Is(err, errCursorPosTimeout) { - return -1, -1 - } - return disable() - } - - pending = append(pending, buf[:read]...) - - indices := rxRcvCursorPos.FindSubmatchIndex(pending) - if indices != nil { - prefix := pending[:indices[0]] - suffix := pending[indices[1]:] - if len(prefix) > 0 || len(suffix) > 0 { - k.mutex.Lock() - k.buf = append(k.buf, prefix...) - k.buf = append(k.buf, suffix...) - k.mutex.Unlock() - } - - y, err := strconv.Atoi(string(pending[indices[2]:indices[3]])) - if err != nil { - return disable() - } - - x, err = strconv.Atoi(string(pending[indices[4]:indices[5]])) - if err != nil { - return disable() - } - - return x, y - } + continue + } - // No cursor response yet: flush anything that cannot be part of it. - start := bytes.LastIndex(pending, []byte{0x1b, '['}) - switch { - case start == -1: - if len(pending) > 0 { - k.mutex.Lock() - k.buf = append(k.buf, pending...) - k.mutex.Unlock() - pending = nil - } - case start > 0: - k.mutex.Lock() - k.buf = append(k.buf, pending[:start]...) - k.mutex.Unlock() - pending = pending[start:] - } + // And if empty, then we should abort. + if len(match) == 0 { + return disable() } + + break } + + // We know that we have a cursor answer, process it. + y, err := strconv.Atoi(match[0][1]) + if err != nil { + return disable() + } + + x, err = strconv.Atoi(match[0][2]) + if err != nil { + return disable() + } + + return x, y } func (k *Keys) readInputFiltered() (keys []byte, err error) { - // Start reading from os.Stdin in the background. - // We will either read keys from user, or an EOF - // send by ourselves, because we pause reading. + // Wait for input to be readable, or for an async refresh request, then + // read one chunk. A refresh request returns errInputWake with no keys. buf := make([]byte, keyScanBufSize) - read, err := k.inputReader().Read(buf) - if err != nil && errors.Is(err, io.EOF) { - return + read, err := k.readStdin(buf) + if err != nil { + return nil, err } // Always attempt to extract cursor position info. @@ -158,93 +115,126 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) { cursor, keys := k.extractCursorPos(buf[:read]) if len(cursor) > 0 { - select { - case k.cursor <- cursor: - default: - } + k.cursor <- cursor } return keys, nil } -func readInputWithTimeout(reader io.Reader, buf []byte, timeout time.Duration) (int, error) { - file, ok := reader.(*os.File) - if !ok { - type readResult struct { - n int - err error - } - ch := make(chan readResult, 1) - go func() { - n, err := reader.Read(buf) - ch <- readResult{n: n, err: err} - }() - select { - case result := <-ch: - return result.n, result.err - case <-time.After(timeout): - return 0, errCursorPosTimeout - } +// readStdin reads one chunk of input, but first waits (via poll) for stdin to +// become readable OR for an async refresh to be requested with RequestRefresh. +// On a refresh request it returns errInputWake with no data. If wake support is +// unavailable (no pipe, or Stdin exposes no pollable fd) it falls back to a +// plain blocking read, so input always works. +func (k *Keys) readStdin(buf []byte) (int, error) { + k.wakeMu.Lock() + ready := k.wakeReady + wakeR := k.wakeR + k.wakeMu.Unlock() + + reader := k.inputReader() + fd, ok := readerFd(reader) + if !ok || !ready { + return reader.Read(buf) } - fds := []unix.PollFd{{ - Fd: int32(file.Fd()), - Events: unix.POLLIN, - }} - - deadline := time.Now().Add(timeout) - for { - remaining := time.Until(deadline) - if remaining <= 0 { - return 0, errCursorPosTimeout - } - - timeoutMs := int(remaining / time.Millisecond) - if timeoutMs <= 0 && remaining > 0 { - timeoutMs = 1 + fds := []unix.PollFd{ + {Fd: int32(fd), Events: unix.POLLIN}, //nolint:gosec // G115: OS file descriptors are small non-negative ints. + {Fd: int32(wakeR), Events: unix.POLLIN}, //nolint:gosec // G115: OS file descriptors are small non-negative ints. } - n, err := unix.Poll(fds, timeoutMs) - if err != nil { + if _, err := unix.Poll(fds, -1); err != nil { if errors.Is(err, unix.EINTR) { continue } - return 0, err + + // Polling failed (e.g. an fd type without poll support): + // fall back to a plain blocking read. + return reader.Read(buf) } - if n == 0 { - return 0, errCursorPosTimeout + + // Real input takes priority, so a coincident wake never delays a + // keystroke; the wake byte stays pending and is serviced once idle. + if fds[0].Revents&(unix.POLLIN|unix.POLLHUP|unix.POLLERR) != 0 { + return reader.Read(buf) } - revents := fds[0].Revents - if revents&(unix.POLLIN|unix.POLLHUP|unix.POLLERR) == 0 { - return 0, errCursorPosTimeout + if fds[1].Revents&unix.POLLIN != 0 { + k.drainWake() + return 0, errInputWake } + } +} - return file.Read(buf) +// InitWake creates the pipe used to interrupt a blocking input read when an +// async refresh is requested. Safe to call repeatedly; on failure it leaves +// wake support disabled (async repaints then coalesce into the next keystroke). +func (k *Keys) InitWake() { + k.CloseWake() + + var p [2]int + if err := unix.Pipe(p[:]); err != nil { + return } + + _ = unix.SetNonblock(p[0], true) + _ = unix.SetNonblock(p[1], true) + + k.wakeMu.Lock() + k.wakeR, k.wakeW = p[0], p[1] + k.wakeReady = true + k.wakeMu.Unlock() } -// GetTerminalResize for Unix systems using SIGWINCH signal -func GetTerminalResize(keys *Keys) <-chan bool { - resizeChan := make(chan bool, 1) +// CloseWake tears down the wake pipe. +func (k *Keys) CloseWake() { + k.wakeMu.Lock() + defer k.wakeMu.Unlock() - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGWINCH) + if !k.wakeReady { + return + } - go func() { - for { - <-sigChan - isWaiting := keys.waiting + _ = unix.Close(k.wakeR) + _ = unix.Close(k.wakeW) + k.wakeReady = false +} - if !isWaiting { - select { - case resizeChan <- true: - default: - } - } +// RequestRefresh wakes an idle Readline loop so it repaints its UI. It is safe +// to call from any goroutine and is the primitive behind async UI updates (for +// instance ui.Hint.SetTransient). It is a no-op when wake support is +// unavailable, in which case the update is shown at the next keystroke. +func (k *Keys) RequestRefresh() { + k.wakeMu.Lock() + defer k.wakeMu.Unlock() + + if !k.wakeReady { + return + } + + // Non-blocking single-byte write. The pipe is drained on wake, and a full + // pipe already means a wake is pending, so dropping the write is harmless. + _, _ = unix.Write(k.wakeW, []byte{0}) +} + +// drainWake empties the wake pipe so a single request does not re-trigger. +func (k *Keys) drainWake() { + var scratch [16]byte + + for { + n, err := unix.Read(k.wakeR, scratch[:]) + if n <= 0 || err != nil { + return } - }() + } +} + +// readerFd returns the file descriptor backing reader, if it exposes one. +func readerFd(reader io.Reader) (int, bool) { + if f, ok := reader.(interface{ Fd() uintptr }); ok { + return int(f.Fd()), true + } - return resizeChan + return 0, false } diff --git a/readline/internal/core/keys_windows.go b/readline/internal/core/keys_windows.go index 0514aae..bed17cc 100644 --- a/readline/internal/core/keys_windows.go +++ b/readline/internal/core/keys_windows.go @@ -4,14 +4,9 @@ package core import ( - "errors" - "io" - "os" - "time" "unsafe" "github.com/chainreactors/tui/readline/inputrc" - "github.com/chainreactors/tui/readline/internal/term" ) // Windows-specific special key codes. @@ -53,19 +48,19 @@ const ( charBackspace = 127 ) -// dwControlKeyState flags from Windows Console API. -const ( - _RIGHT_ALT_PRESSED = 0x0001 - _LEFT_ALT_PRESSED = 0x0002 - _RIGHT_CTRL_PRESSED = 0x0004 - _LEFT_CTRL_PRESSED = 0x0008 - _SHIFT_PRESSED = 0x0010 -) - func init() { Stdin = newRawReader() } +// GetTerminalResize sends booleans over a channel to notify resize events on Windows. +// This functions uses the keys reader because on Windows, resize events are sent through +// stdin, not with syscalls like unix's syscall.SIGWINCH. +func GetTerminalResize(keys *Keys) <-chan bool { + keys.resize = make(chan bool, 1) + + return keys.resize +} + // readInputFiltered on Windows needs to check for terminal resize events. func (k *Keys) readInputFiltered() (keys []byte, err error) { for { @@ -75,7 +70,11 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) { buf := make([]byte, keyScanBufSize) read, err := k.inputReader().Read(buf) - if err != nil && errors.Is(err, io.EOF) { + if err != nil { + // EOF (stream closed) or any other read failure (e.g. a revoked + // console handle): propagate it so WaitAvailableKeys records EOF or + // surfaces the error, instead of swallowing it and spinning on a + // dead stdin. Mirrors the Unix reader's behaviour. return keys, err } @@ -100,28 +99,29 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) { } } -// rawReader translates Windows input to ANSI sequences, -// to provide the same behavior as Unix terminals. -type rawReader struct{} +// InitWake is a no-op on Windows: the async-refresh wake (poll-based on Unix) +// is not yet supported here, so async UI updates appear at the next keystroke. +func (k *Keys) InitWake() {} -// newRawReader returns a new rawReader for Windows. -func newRawReader() *rawReader { - return new(rawReader) -} +// CloseWake is a no-op on Windows. +func (k *Keys) CloseWake() {} -// isCtrl returns true if Ctrl is pressed in this event's dwControlKeyState. -func isCtrl(state dword) bool { - return state&(_LEFT_CTRL_PRESSED|_RIGHT_CTRL_PRESSED) != 0 -} +// RequestRefresh is a no-op on Windows (async wake unsupported); async UI +// updates appear at the next keystroke. +func (k *Keys) RequestRefresh() {} -// isAlt returns true if Alt is pressed in this event's dwControlKeyState. -func isAlt(state dword) bool { - return state&(_LEFT_ALT_PRESSED|_RIGHT_ALT_PRESSED) != 0 +// rawReader translates Windows input to ANSI sequences, +// to provide the same behavior as Unix terminals. +type rawReader struct { + ctrlKey bool + altKey bool + shiftKey bool } -// isShift returns true if Shift is pressed in this event's dwControlKeyState. -func isShift(state dword) bool { - return state&_SHIFT_PRESSED != 0 +// newRawReader returns a new rawReader for Windows. +func newRawReader() *rawReader { + r := new(rawReader) + return r } // Read reads input record from stdin on Windows. @@ -142,9 +142,18 @@ next: return 0, err } - // Skip focus events. + // First deal with terminal focus events, which reset some stuff if ir.EventType == EVENT_FOCUS { - goto next + ker := (*_FOCUS_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0])) + + // If are with Tab Active and losing the focus, + // ignore this Alt key that is most likely the "Alt-Tab" + // system shortcut on Windows for Tab switching. + // QUESTION: Should we also do this to some other modifiers ? + if !ker.bSetFocus && r.altKey { + r.altKey = false + goto next + } } // Keep resize events for the display engine to use. @@ -156,55 +165,49 @@ next: goto next } + // Reset modifiers if key is released. ker := (*_KEY_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0])) - - // Skip key-up events. - if ker.bKeyDown == 0 { - goto next - } - - // Skip standalone modifier key presses. - switch ker.wVirtualKeyCode { - case VK_CONTROL, VK_LCONTROL, VK_RCONTROL, - VK_MENU, - VK_SHIFT, VK_LSHIFT, VK_RSHIFT: + if ker.bKeyDown == 0 { // keyup + if r.ctrlKey || r.altKey || r.shiftKey { + switch ker.wVirtualKeyCode { + case VK_RCONTROL, VK_LCONTROL, VK_CONTROL: + r.ctrlKey = false + case VK_MENU: // alt + r.altKey = false + case VK_SHIFT, VK_LSHIFT, VK_RSHIFT: + r.shiftKey = false + } + } goto next } - // Use per-event dwControlKeyState for reliable modifier detection. - // This avoids stale modifier state when the terminal synthesizes - // key events (e.g., bracketed paste after Ctrl+V interception). - ctrlKey := isCtrl(ker.dwControlKeyState) - altKey := isAlt(ker.dwControlKeyState) - shiftKey := isShift(ker.dwControlKeyState) - - // Keypad, special and arrow keys (unicodeChar == 0). + // Keypad, special and arrow keys. if ker.unicodeChar == 0 { - if modifiers, target := r.translateSeq(ker, ctrlKey, altKey, shiftKey); target != 0 { + if modifiers, target := r.translateSeq(ker); target != 0 { return r.writeEsc(buf, append(modifiers, target)...) } goto next } - { - char := rune(ker.unicodeChar) - - // Encode keys with modifiers. - switch { - case shiftKey && char == charTab: - return r.writeEsc(buf, 91, 90) - case ctrlKey && char == charBackspace: - char = charCtrlH - case !ctrlKey && char == charCtrlH: - char = charBackspace - case ctrlKey: - char = inputrc.Encontrol(char) - case altKey: - char = inputrc.Enmeta(char) - } + char := rune(ker.unicodeChar) - return r.write(buf, char) + // Encode keys with modifiers. + // Deal with the last (Windows) exceptions to the rule. + switch { + case r.shiftKey && char == charTab: + return r.writeEsc(buf, 91, 90) + case r.ctrlKey && char == charBackspace: + char = charCtrlH + case !r.ctrlKey && char == charCtrlH: + char = charBackspace + case r.ctrlKey: + char = inputrc.Encontrol(char) + case r.altKey: + char = inputrc.Enmeta(char) } + + // Else, the key is a normal character. + return r.write(buf, char) } // Close is a stub to satisfy io.Closer. @@ -223,17 +226,29 @@ func (r *rawReader) write(b []byte, char ...rune) (int, error) { return n, nil } -func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD, ctrlKey, altKey, shiftKey bool) (modifiers []rune, target rune) { - // Encode keys with modifiers by default. +func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD) (modifiers []rune, target rune) { + // Encode keys with modifiers by default, + // unless the modifier is pressed alone. modifiers = append(modifiers, 91) // Modifiers add a default sequence, which is the good sequence for arrow keys by default. + // The first rune is this sequence might be modified below, if the target is a special key + // but not an arrow key. + switch ker.wVirtualKeyCode { + case VK_RCONTROL, VK_LCONTROL, VK_CONTROL: + r.ctrlKey = true + case VK_MENU: // alt + r.altKey = true + case VK_SHIFT, VK_LSHIFT, VK_RSHIFT: + r.shiftKey = true + } + switch { - case ctrlKey: + case r.ctrlKey: modifiers = append(modifiers, 49, 59, 53) - case altKey: + case r.altKey: modifiers = append(modifiers, 49, 59, 51) - case shiftKey: + case r.shiftKey: modifiers = append(modifiers, 49, 59, 50) } @@ -279,27 +294,3 @@ func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD, ctrlKey, altKey, shiftK return } - -// GetTerminalResize sends booleans over a channel to notify resize events on Windows. -// This functions uses the keys reader because on Windows, resize events are sent through -// stdin, not with syscalls like unix's syscall.SIGWINCH. -func GetTerminalResize(keys *Keys) <-chan bool { - keys.resize = make(chan bool, 1) - prevWidth, prevHeight, _ := term.GetSize(int(os.Stdout.Fd())) - go func() { - for { - width, height, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - break - } - - if width != prevWidth || height != prevHeight { - prevWidth, prevHeight = width, height - //fmt.Println("windows resize") - keys.resize <- true - } - time.Sleep(500 * time.Millisecond) - } - }() - return keys.resize -} diff --git a/readline/internal/core/line.go b/readline/internal/core/line.go index ddf14e2..be9744a 100644 --- a/readline/internal/core/line.go +++ b/readline/internal/core/line.go @@ -37,6 +37,7 @@ func (l *Line) Insert(pos int, chars ...rune) { for end > 0 && chars[end-1] == 0 { end-- } + chars = chars[:end] // Invalid position cancels the insertion @@ -96,7 +97,6 @@ func (l *Line) CutRune(pos int) { default: *l = slices.Delete([]rune(*l), pos, pos+1) } - } // Len returns the length of the line. @@ -284,32 +284,34 @@ func DisplayLine(l *Line, indent int) { for _, r := range *l { if r == '\n' { builtLine.WriteString(color.BgDefault) + if lineLen < term.GetWidth() { builtLine.WriteString(term.ClearLineAfter) } + builtLine.WriteString(term.NewlineReturn) - builtLine.WriteString(fmt.Sprintf("\x1b[%dC", indent)) // Equivalent of term.MoveCursorForwards + fmt.Fprintf(&builtLine, "\x1b[%dC", indent) // Equivalent of term.MoveCursorForwards builtLine.WriteString(term.ClearLineBefore) lineLen = 0 } else { builtLine.WriteRune(r) + lineLen++ } - } if l.Len() > 0 && (*l)[l.Len()-1] == '\n' { builtLine.WriteString(color.BgDefault) builtLine.WriteString(term.ClearLineAfter) builtLine.WriteString(term.NewlineReturn) - builtLine.WriteString(fmt.Sprintf("\x1b[%dC", indent)) // Equivalent of term.MoveCursorForwards + fmt.Fprintf(&builtLine, "\x1b[%dC", indent) // Equivalent of term.MoveCursorForwards builtLine.WriteString(term.ClearLineBefore) } builtLine.WriteString(color.BgDefault) - term.Print(builtLine.String()) + term.WriteString(builtLine.String()) } // CoordinatesLine returns the number of real terminal lines on which the input line spans, considering @@ -346,6 +348,7 @@ func CoordinatesLine(l *Line, indent int) (int, int) { // the number of newlines - 1. func (l *Line) Lines() int { var count int + for _, r := range *l { if r == inputrc.Newline { count++ @@ -428,7 +431,9 @@ func (l *Line) Tokenize(cpos int) ([]string, int, int) { var index, pos int var punc bool - split := make([]string, 1) + // Start with a single empty accumulator word; the loop appends to the + // last element via split[len(split)-1], so it must be non-empty. + split := []string{""} for i, char := range line { switch { @@ -492,7 +497,9 @@ func (l *Line) TokenizeSpace(cpos int) ([]string, int, int) { cpos = l.checkPosRange(cpos) var index, pos int - split := make([]string, 1) + // Start with a single empty accumulator word; the loop appends to the + // last element via split[len(split)-1], so it must be non-empty. + split := []string{""} var newline bool for i, char := range line { diff --git a/readline/internal/core/line_test.go b/readline/internal/core/line_test.go index b33208c..4dcc9dd 100644 --- a/readline/internal/core/line_test.go +++ b/readline/internal/core/line_test.go @@ -1,9 +1,13 @@ package core import ( + "bytes" + "io" + "os" "reflect" "testing" + "github.com/chainreactors/tui/readline/internal/color" "github.com/chainreactors/tui/readline/internal/term" ) @@ -57,7 +61,7 @@ func TestLine_Insert(t *testing.T) { } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { + t.Run(test.name, func(_ *testing.T) { test.l.Insert(test.args.pos, test.args.r...) }) @@ -115,7 +119,7 @@ func TestLine_InsertBetween(t *testing.T) { } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { + t.Run(test.name, func(_ *testing.T) { test.l.InsertBetween(test.args.bpos, test.args.epos, test.args.r...) }) @@ -165,7 +169,7 @@ func TestLine_Cut(t *testing.T) { } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { + t.Run(test.name, func(_ *testing.T) { test.l.Cut(test.args.bpos, test.args.epos) }) @@ -214,7 +218,7 @@ func TestLine_CutRune(t *testing.T) { } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { + t.Run(test.name, func(_ *testing.T) { test.l.CutRune(test.args.pos) }) @@ -1037,6 +1041,11 @@ func TestLine_TokenizeBlock(t *testing.T) { } func TestDisplayLine(t *testing.T) { + indent := 10 + line := Line("basic -f \"commands.go,line.go\" -cp=/usr --option [value1 value2]") + multiline := Line("basic -f \"commands.go \nanother testing\" --alternate \"another\nquote\" -v { expression here } -a [value1 value2]") + longMultiline := Line("longer than 80 characters, which is the term width reported when running go test \nanother line ending on newline \n") + type args struct { indent int } @@ -1044,15 +1053,68 @@ func TestDisplayLine(t *testing.T) { name string l *Line args args + want string }{ - // TODO: Add test cases. + { + name: "Empty line buffer", + l: new(Line), + args: args{indent: indent}, + want: color.BgDefault, + }, + { + name: "Single line buffer", + l: &line, + args: args{indent: indent}, + want: string(line) + color.BgDefault, + }, + { + name: "Multiline buffer", + l: &multiline, + args: args{indent: indent}, + want: "basic -f \"commands.go " + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + "another testing\" --alternate \"another" + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + "quote\" -v { expression here } -a [value1 value2]" + color.BgDefault, + }, + { + name: "Long multiline buffer", + l: &longMultiline, + args: args{indent: indent}, + want: "longer than 80 characters, which is the term width reported when running go test " + color.BgDefault + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + "another line ending on newline " + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + color.BgDefault, + }, } + savedStdout := os.Stdout + for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + os.Stdout = savedStdout + + t.Fatalf("pipe: %s", err) + } + + os.Stdout = w + + t.Run(tt.name, func(_ *testing.T) { DisplayLine(tt.l, tt.args.indent) }) + + w.Close() + + var buf bytes.Buffer + io.Copy(&buf, r) + + if buf.String() != tt.want { + t.Errorf("DisplayLine: got\n%q\nwant\n%q", buf.String(), tt.want) + } + + r.Close() } + + os.Stdout = savedStdout } func TestCoordinatesLine(t *testing.T) { @@ -1064,8 +1126,7 @@ func TestCoordinatesLine(t *testing.T) { getTermWidth = func() int { return 80 } type args struct { - indent int - suggested string + indent int } tests := []struct { name string @@ -1074,6 +1135,13 @@ func TestCoordinatesLine(t *testing.T) { wantX int wantY int }{ + { + name: "Empty line buffer", + l: new(Line), + args: args{indent: indent}, + wantY: 0, + wantX: indent, + }, { name: "Single line buffer", l: &line, diff --git a/readline/internal/core/selection.go b/readline/internal/core/selection.go index 43eb01f..187fbed 100644 --- a/readline/internal/core/selection.go +++ b/readline/internal/core/selection.go @@ -304,9 +304,10 @@ func (s *Selection) Surround(bchar, echar rune) { defer s.Reset() - var buf []rune + text := []rune(s.Text()) + buf := make([]rune, 0, 1+len(text)+1) buf = append(buf, bchar) - buf = append(buf, []rune(s.Text())...) + buf = append(buf, text...) buf = append(buf, echar) // The begin and end positions of the selection @@ -340,7 +341,7 @@ func (s *Selection) SelectAWord() (bpos, epos int) { // And only select spaces after it if the word selected is not preceded // by spaces as well, or if we started the selection within this word. - bpos, _ = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos) + bpos = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos) if !s.Active() || bpos < cpos { s.Mark(bpos) @@ -375,7 +376,7 @@ func (s *Selection) SelectABlankWord() (bpos, epos int) { // And only select spaces after it if the word selected is not preceded // by spaces as well, or if we started the selection within this word. - bpos, _ = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos) + bpos = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos) if !s.Active() || bpos < s.cursor.Pos() { s.Mark(bpos) @@ -722,15 +723,13 @@ func isSpace(char rune) bool { // adjustWordSelection adjust the beginning and end of a word (blank or not) selection, depending // on whether it's surrounded by spaces, and if selection started from a whitespace or within word. -func (s *Selection) adjustWordSelection(_, under, after bool, bpos int) (int, int) { - var epos int - +func (s *Selection) adjustWordSelection(_, under, after bool, bpos int) int { if after && !under { s.cursor.Inc() s.cursor.ToFirstNonSpace(true) s.cursor.Dec() } else if !after { - epos = s.cursor.Pos() + epos := s.cursor.Pos() s.cursor.Set(bpos - 1) s.cursor.ToFirstNonSpace(false) s.cursor.Inc() @@ -738,9 +737,7 @@ func (s *Selection) adjustWordSelection(_, under, after bool, bpos int) (int, in s.cursor.Set(epos) } - epos = s.cursor.Pos() - - return bpos, epos + return bpos } func (s *Selection) matchKeyword(buf []rune, bbpos int, next bool) (name string, found bool, bpos, epos int) { @@ -797,10 +794,10 @@ func (s *Selection) matchKeyword(buf []rune, bbpos int, next bool) (name string, if next { done = func(i int) bool { return i <= len(matchersNames) } - move = func(inc int) int { return kpos + 1 } + move = func(_ int) int { return kpos + 1 } } else { done = func(i int) bool { return i > 0 } - move = func(inc int) int { return kpos - 1 } + move = func(_ int) int { return kpos - 1 } } // Try the different matchers until one succeeds, and select the first/last capturing group. diff --git a/readline/internal/core/selection_test.go b/readline/internal/core/selection_test.go index b484046..2877442 100644 --- a/readline/internal/core/selection_test.go +++ b/readline/internal/core/selection_test.go @@ -1259,7 +1259,7 @@ func TestSelection_SelectKeyword(t *testing.T) { var gotKbpos, gotKepos int var gotMatch bool - for i := 0; i < test.args.cycles; i++ { + for range test.args.cycles { gotKbpos, gotKepos, gotMatch = sel.SelectKeyword(test.args.bpos, test.args.epos, test.args.next) } diff --git a/readline/internal/display/completion_test.go b/readline/internal/display/completion_test.go new file mode 100644 index 0000000..e1b46b1 --- /dev/null +++ b/readline/internal/display/completion_test.go @@ -0,0 +1,104 @@ +//go:build unix + +package display_test + +import ( + "strings" + "testing" + "time" +) + +// TestAsyncRefreshCompletions proves #99 on the async-refresh rail: completions +// produced asynchronously (a background goroutine grows the result set and +// calls Shell.RefreshCompletions) rebuild an already-open menu in place, with +// no keystroke from the user. +func TestAsyncRefreshCompletions(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "P> ", + cols: 80, + rows: 24, + asyncComp: true, + asyncMS: 400, + }) + c.waitForScreen("P>") + + // Open the completion menu (possible-completions): it displays the initial + // candidates, and "alpaca" is not among them yet. + c.send("\x1b?") + screen := c.waitForScreen("alpha") + + if strings.Contains(screen, "alpaca") { + t.Fatalf("alpaca should not be present before the async refresh:\n%s", screen) + } + + // Send NO further input: the async producer grows the result set and calls + // RefreshCompletions, which must regenerate the open menu in place so the + // new candidate appears on its own. + c.waitForScreen("alpaca") +} + +// TestAsyncRefreshCompletionsNoMenuIsNoop verifies RefreshCompletions is a clean +// no-op when no menu is active: it must not spontaneously open one. +func TestAsyncRefreshCompletionsNoMenuIsNoop(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "P> ", + cols: 80, + rows: 24, + asyncComp: true, + asyncMS: 300, + }) + c.waitForScreen("P>") + + // Do not open a menu. Wait past the async RefreshCompletions call; with no + // active menu it must do nothing, so no candidate ever appears. + time.Sleep(900 * time.Millisecond) + + screen := c.screen() + if strings.Contains(screen, "alpha") || strings.Contains(screen, "alpaca") { + t.Fatalf("RefreshCompletions must no-op when no menu is active, but a menu appeared:\n%s", screen) + } +} + +// TestAsyncRefreshKeepsSelection verifies #99 option B for an explicit menu: +// when a candidate is selected, an async RefreshCompletions both (a) updates the +// menu in place with newly produced candidates and (b) keeps the selection on +// the same candidate (matched by content), rather than freezing the menu or +// dropping the selection. The new candidate sorts first, so the grid re-sorts — +// proving the restore is content-based, not positional. +func TestAsyncRefreshKeepsSelection(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "P> ", + cols: 80, + rows: 24, + asyncComp: true, + asyncMS: 1500, // fire after we have selected a candidate + }) + c.waitForScreen("P>") + + // Show the menu (no selection yet) and wait for the initial candidates. + c.send("\x1b?") + c.waitForScreen("alpha") + + // Select a candidate by navigating down to "alpine" (virtual insertion shows + // it in the input line). + c.send("\x1b[B") // select alpha + c.send("\x1b[B") // select alpine + screen := c.waitForScreen("P> alpine") + + if strings.Contains(screen, "alpaca") { + t.Fatalf("alpaca should not be present before the async refresh:\n%s", screen) + } + + // The async producer adds "alpaca" (which sorts first) and calls + // RefreshCompletions while "alpine" is selected: the menu must update AND + // the selection must persist. + c.waitForScreen("alpaca") + + final := c.screen() + first := strings.TrimRight(strings.SplitN(final, "\n", 2)[0], " ") + + if first != "P> alpine" { + t.Fatalf("selection not preserved across async refresh: first line = %q want %q\n%s", + first, "P> alpine", final) + } +} diff --git a/readline/internal/display/display_unix.go b/readline/internal/display/display_unix.go index 5eddcd3..78c75b5 100644 --- a/readline/internal/display/display_unix.go +++ b/readline/internal/display/display_unix.go @@ -4,23 +4,22 @@ package display import ( - "github.com/chainreactors/tui/readline/internal/core" + "os" + "os/signal" + "syscall" + "github.com/chainreactors/tui/readline/internal/term" ) // WatchResize redisplays the interface on terminal resize events. func WatchResize(eng *Engine) chan<- bool { - resizeChannel := core.GetTerminalResize(eng.keys) done := make(chan bool, 1) - output := term.Output() - control := term.CurrentControl() + + resizeChannel := make(chan os.Signal, 1) + signal.Notify(resizeChannel, syscall.SIGWINCH) unregister := term.OnResize(func(_, _ int) { - if eng.keys != nil && !eng.keys.IsReading() && !eng.keys.IsWaiting() { - restore := term.Activate(output, control) - eng.completer.GenerateCached() - eng.Refresh() - restore() - } + eng.completer.RequestRegen() + eng.keys.RequestRefresh() }) go func() { @@ -28,12 +27,12 @@ func WatchResize(eng *Engine) chan<- bool { for { select { case <-resizeChannel: - if eng.keys != nil && !eng.keys.IsReading() && !eng.keys.IsWaiting() { - restore := term.Activate(output, control) - eng.completer.GenerateCached() - eng.Refresh() - restore() - } + // Route the regeneration + repaint through the input wake so + // they run on the Readline goroutine, instead of mutating the + // completion/display state and writing to stdout from here + // (which races with the main loop). + eng.completer.RequestRegen() + eng.keys.RequestRefresh() case <-done: return } diff --git a/readline/internal/display/drift_test.go b/readline/internal/display/drift_test.go new file mode 100644 index 0000000..40940eb --- /dev/null +++ b/readline/internal/display/drift_test.go @@ -0,0 +1,164 @@ +//go:build unix + +package display_test + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// TestNoPromptDriftWithHint reproduces a reported bug: when a hint is displayed +// below the input line, every refresh repositioned the prompt one row too high, +// so the whole UI crept upward by one row per keystroke. The anchor (top) line +// of a multi-line prompt must stay on the same screen row as the user types. +func TestNoPromptDriftWithHint(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "ANCHOR\nP> ", + cols: 80, + rows: 24, + transient: "STATUS-HINT", + }) + c.waitForScreen("ANCHOR") + c.waitForScreen("STATUS-HINT") + + chars := []string{"a", "b", "c", "d"} + rows := make([]int, 0, len(chars)) + + for _, ch := range chars { + c.send(ch) + time.Sleep(150 * time.Millisecond) + + screen := c.screen() + rows = append(rows, rowIndex(screen, "ANCHOR")) + } + + for i, r := range rows { + if r != rows[0] { + t.Fatalf("prompt drifted: ANCHOR row per keystroke = %v (moved at step %d)\nlast screen:\n%s", + rows, i, c.screen()) + } + } +} + +// TestNoPromptDriftWithHintAndAutocomplete is the compound case from the report: +// autocomplete menu + hint, multi-line prompt. The anchor line must not drift. +func TestNoPromptDriftWithHintAndAutocomplete(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "ANCHOR\nP> ", + cols: 80, + rows: 24, + autocomplete: true, + hintProvider: true, + }) + c.waitForScreen("ANCHOR") + + chars := strings.Split("alp", "") + rows := make([]int, 0, len(chars)) + + for _, ch := range chars { + c.send(ch) + time.Sleep(200 * time.Millisecond) + + screen := c.screen() + rows = append(rows, rowIndex(screen, "ANCHOR")) + } + + for i, r := range rows { + if r != rows[0] { + t.Fatalf("prompt drifted with autocomplete: ANCHOR row per keystroke = %v (moved at step %d)\nlast screen:\n%s", + rows, i, c.screen()) + } + } +} + +// TestNoPromptDriftAcrossAsyncWakes reproduces the core bug: each async-refresh +// wake (e.g. a transient hint pushed from another goroutine) repainted the UI +// one row too high, so repeated async updates crept the prompt upward — even +// with no keystroke. The anchor line must stay put across many wakes. +func TestNoPromptDriftAcrossAsyncWakes(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "ANCHOR\nP> ", + cols: 80, + rows: 24, + asyncMS: 150, + asyncRepeat: 5, + }) + c.waitForScreen("ANCHOR") + + rows := make([]int, 0, 5) + + for i := range 5 { + c.waitForScreen(fmt.Sprintf("ASYNCPING-%d", i)) + rows = append(rows, rowIndex(c.screen(), "ANCHOR")) + } + + for i, r := range rows { + if r != rows[0] { + t.Fatalf("prompt drifted across async wakes: ANCHOR row per update = %v (moved at update %d)\nlast screen:\n%s", + rows, i, c.screen()) + } + } +} + +// TestNoPromptDriftAsyncWakesWithRightPrompt is the closest match to the example +// console: a multi-line prompt with a right-side prompt (reaching the far-right +// column), updated by repeated async transient hints. +func TestNoPromptDriftAsyncWakesWithRightPrompt(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "ANCHOR\nP> ", + cols: 80, + rows: 24, + rightPrompt: "12:00:00.000", + asyncMS: 150, + asyncRepeat: 5, + }) + c.waitForScreen("ANCHOR") + + rows := make([]int, 0, 5) + + for i := range 5 { + c.waitForScreen(fmt.Sprintf("ASYNCPING-%d", i)) + rows = append(rows, rowIndex(c.screen(), "ANCHOR")) + } + + for i, r := range rows { + if r != rows[0] { + t.Fatalf("prompt drifted across async wakes (with right prompt): ANCHOR row = %v (moved at %d)\n%s", + rows, i, c.screen()) + } + } +} + +// TestNoPromptDriftTwoHintLanes reproduces the root cause directly: with TWO +// active hint lanes (a provider hint plus an async transient), the hint-row +// count was over-counted, so each refresh crept the prompt up one row. +func TestNoPromptDriftTwoHintLanes(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "ANCHOR\nP> ", + cols: 80, + rows: 24, + hintProvider: true, // provider hint = lane 1 (once the line is non-empty) + asyncMS: 150, + asyncRepeat: 5, // transient hint = lane 2 + }) + c.waitForScreen("ANCHOR") + + c.send("x") // make the provider hint non-empty + c.waitForScreen("HINT:x") + + rows := make([]int, 0, 5) + + for i := range 5 { + c.waitForScreen(fmt.Sprintf("ASYNCPING-%d", i)) + rows = append(rows, rowIndex(c.screen(), "ANCHOR")) + } + + for i, r := range rows { + if r != rows[0] { + t.Fatalf("prompt drifted with two hint lanes: ANCHOR row = %v (moved at %d)\n%s", + rows, i, c.screen()) + } + } +} diff --git a/readline/internal/display/engine.go b/readline/internal/display/engine.go index 46c8039..d5100b9 100644 --- a/readline/internal/display/engine.go +++ b/readline/internal/display/engine.go @@ -1,6 +1,7 @@ package display import ( + "regexp" "strings" "github.com/chainreactors/tui/readline/inputrc" @@ -33,12 +34,17 @@ type Engine struct { compRows int primaryPrinted bool + // commentRegex is the compiled comment-highlight pattern. It is rebuilt + // only when the comment-begin option changes, not on every refresh. + commentToken string + commentRegex *regexp.Regexp + // UI components keys *core.Keys line *core.Line suggested core.Line cursor *core.Cursor - inlineSuggestion string // Inline suggestion (fish-style gray text) + inlineSuggestion string selection *core.Selection histories *history.Sources prompt *ui.Prompt @@ -86,7 +92,6 @@ func (e *Engine) inlineSuggestionApplies(currentLine string) bool { if e.inlineSuggestion == "" { return false } - if e.cursor.Pos() != e.line.Len() { return false } @@ -94,43 +99,6 @@ func (e *Engine) inlineSuggestionApplies(currentLine string) bool { return strings.HasPrefix(e.inlineSuggestion, currentLine) && len(e.inlineSuggestion) > len(currentLine) } -// Refresh recomputes and redisplays the entire readline interface, except -// the first lines of the primary prompt when the latter is a multiline one. -func (e *Engine) Refresh() { - term.Print(term.HideCursor) - - // Trigger autocomplete early so that inline suggestions are ready - // before displayLine() is called. This ensures fish-style suggestions - // appear immediately as the user types. - e.completer.Autocomplete() - - // Go back to the first column, and if the primary prompt - // was not printed yet, back up to the line's beginning row. - term.MoveCursorBackwards(term.GetWidth()) - - if !e.primaryPrinted { - term.MoveCursorUp(e.cursorRow) - } - - // Print either all or the last line of the prompt. - e.prompt.LastPrint() - - // Get all positions required for the redisplay to come: - // prompt end (thus indentation), cursor positions, etc. - e.computeCoordinates(true) - - // Print the line, and any of the secondary and right prompts. - e.displayLine() - e.displayMultilinePrompts() - - // Display hints and completions, go back - // to the start of the line, then to cursor. - e.displayHelpers() - e.cursorHintToLineStart() - e.lineStartToCursorPos() - term.Print(term.ShowCursor) -} - // PrintPrimaryPrompt redraws the primary prompt. // There are relatively few cases where you want to use this. // It is currently only used when using clear-screen commands. @@ -141,8 +109,11 @@ func (e *Engine) PrintPrimaryPrompt() { // ClearHelpers clears the hint and completion sections below the line. func (e *Engine) ClearHelpers() { + term.BeginBuffer() + defer term.EndBuffer() + e.CursorBelowLine() - term.Print(term.ClearScreenBelow) + term.WriteString(term.ClearScreenBelow) term.MoveCursorUp(1) term.MoveCursorUp(e.lineRows) @@ -161,7 +132,9 @@ func (e *Engine) ResetHelpers() { // hints, completions and some right prompts, the shell will put the // display at the start of the line immediately following the line. func (e *Engine) AcceptLine() { - e.ClearInlineSuggestion() + term.BeginBuffer() + defer term.EndBuffer() + e.CursorToLineStart() e.computeCoordinates(false) @@ -170,14 +143,14 @@ func (e *Engine) AcceptLine() { term.MoveCursorBackwards(term.GetWidth()) term.MoveCursorDown(e.lineRows) term.MoveCursorForwards(e.lineCol) - term.Print(term.ClearScreenBelow) + term.WriteString(term.ClearScreenBelow) // Reprint the right-side prompt if it's not a tooltip one. e.prompt.RightPrint(e.lineCol, false) // Go below this non-suggested line and clear everything. term.MoveCursorBackwards(term.GetWidth()) - term.Print(term.NewlineReturn) + term.WriteString(term.NewlineReturn) } // RefreshTransient goes back to the first line of the input buffer @@ -187,6 +160,9 @@ func (e *Engine) RefreshTransient() { return } + term.BeginBuffer() + defer term.EndBuffer() + // Go to the beginning of the primary prompt. e.CursorToLineStart() term.MoveCursorUp(e.prompt.PrimaryUsed()) @@ -194,7 +170,7 @@ func (e *Engine) RefreshTransient() { // And redisplay the transient/primary/line. e.prompt.TransientPrint() e.displayLine() - term.Print(term.NewlineReturn) + term.WriteString(term.NewlineReturn) } // CursorToLineStart moves the cursor just after the primary prompt. @@ -213,23 +189,7 @@ func (e *Engine) CursorToLineStart() { func (e *Engine) CursorBelowLine() { term.MoveCursorUp(e.cursorRow) term.MoveCursorDown(e.lineRows) - term.Print(term.NewlineReturn) -} - -// lineStartToCursorPos can be used if the cursor is currently -// at the very start of the input line, that is just after the -// last character of the prompt. -func (e *Engine) lineStartToCursorPos() { - term.MoveCursorDown(e.cursorRow) - term.MoveCursorBackwards(term.GetWidth()) - term.MoveCursorForwards(e.cursorCol) -} - -// cursor is on the line below the last line of input. -func (e *Engine) cursorHintToLineStart() { - term.MoveCursorUp(1) - term.MoveCursorUp(e.lineRows - e.cursorRow) - e.CursorToLineStart() + term.WriteString(term.NewlineReturn) } func (e *Engine) computeCoordinates(suggested bool) { @@ -241,15 +201,28 @@ func (e *Engine) computeCoordinates(suggested bool) { e.suggested = e.histories.Suggest(e.line) } - // Get the position of the line's beginning by querying - // the terminal for the cursor position. - e.startCols, e.startRows = e.keys.GetCursorPos() + // Recompute the passive provider hint from the current line, so it tracks + // the input as it changes. Runs every refresh, on the main loop goroutine. + e.hint.UpdateProvided([]rune(*e.line), e.cursor.Pos()) + + // Get the position of the line's beginning by querying the terminal for the + // cursor position. Some environments (PTY test harnesses, minimal emulators, + // constrained CI) don't reliably answer the "ESC[6n" query, so consumers can + // turn the cursor-position-probe option off; we then fall back to a position + // derived from the printed prompt width. + if e.opts.GetBool("cursor-position-probe") { + e.startCols, e.startRows = e.keys.GetCursorPos() + } else { + e.startCols, e.startRows = -1, -1 + } if e.startCols > 0 { e.startCols-- } - // Cursor position might be misleading if invalid (negative). + // Cursor column might be misleading if invalid (negative), or unavailable + // because probing is disabled: fall back to the printed prompt width. This + // is exact whenever the input line starts at column 0 (the common case). if e.startCols == -1 { e.startCols = e.prompt.LastUsed() } @@ -257,16 +230,16 @@ func (e *Engine) computeCoordinates(suggested bool) { e.cursorCol, e.cursorRow = core.CoordinatesCursor(e.cursor, e.startCols) // Get the number of rows used by the line, and the end line X pos. - displayLine := e.line currentLine := string(*e.line) if e.opts.GetBool("history-autosuggest") && suggested && len(e.suggested) > e.line.Len() { - displayLine = &e.suggested + e.lineCol, e.lineRows = core.CoordinatesLine(&e.suggested, e.startCols) } else if e.inlineSuggestionApplies(currentLine) { inlineLine := core.Line{} inlineLine.Set([]rune(e.inlineSuggestion)...) - displayLine = &inlineLine + e.lineCol, e.lineRows = core.CoordinatesLine(&inlineLine, e.startCols) + } else { + e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols) } - e.lineCol, e.lineRows = core.CoordinatesLine(displayLine, e.startCols) e.primaryPrinted = false } @@ -290,17 +263,13 @@ func (e *Engine) displayLine() { // Apply visual selections highlighting if any line = e.highlightLine([]rune(line), *e.selection) - // Track if we added any suggestion suffix + // Get the subset of the suggested line to print. suggestionAdded := false - - // Get the subset of the suggested line to print (history autosuggest). if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") { line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset suggestionAdded = true } - // If no history suggestion was added, try the inline suggestion. - // Only show when cursor is at end of line. currentLine := string(*e.line) if !suggestionAdded && e.inlineSuggestionApplies(currentLine) { suffix := e.inlineSuggestion[len(currentLine):] @@ -316,49 +285,9 @@ func (e *Engine) displayLine() { // Adjust the cursor if the line fits exactly in the terminal width. if e.lineCol == 0 { - term.Print(term.NewlineReturn) - term.Print(term.ClearLineAfter) - } -} - -func (e *Engine) displayMultilinePrompts() { - // If we have more than one line, write the columns. - if e.line.Lines() > 1 { - term.MoveCursorUp(e.lineRows) - term.MoveCursorBackwards(term.GetWidth()) - e.prompt.MultilineColumnPrint() + term.WriteString(term.NewlineReturn) + term.WriteString(term.ClearLineAfter) } - - // Then if we have a line at all, rewrite the last column - // character with any secondary prompt available. - if e.line.Lines() > 0 { - term.MoveCursorBackwards(term.GetWidth()) - e.prompt.SecondaryPrint() - term.MoveCursorBackwards(term.GetWidth()) - term.MoveCursorForwards(e.lineCol) - } - - // Then prompt the right-sided prompt if possible. - e.prompt.RightPrint(e.lineCol, true) -} - -// displayHelpers renders the hint and completion sections. -// It assumes that the cursor is on the last line of input, -// and goes back to this same line after displaying this. -func (e *Engine) displayHelpers() { - term.Print(term.NewlineReturn) - term.Print(term.ClearScreenBelow) - - // Display hint and completions. - ui.DisplayHint(e.hint) - e.hintRows = ui.CoordinatesHint(e.hint) - completion.Display(e.completer, e.AvailableHelperLines()) - e.compRows = completion.Coordinates(e.completer) - - // Go back to the first line below the input line. - term.MoveCursorBackwards(term.GetWidth()) - term.MoveCursorUp(e.compRows) - term.MoveCursorUp(ui.CoordinatesHint(e.hint)) } // AvailableHelperLines returns the number of lines available below the hint section. diff --git a/readline/internal/display/highlight.go b/readline/internal/display/highlight.go index 851d999..ff0e769 100644 --- a/readline/internal/display/highlight.go +++ b/readline/internal/display/highlight.go @@ -11,7 +11,9 @@ import ( "github.com/chainreactors/tui/readline/internal/core" ) -var rxColorEscape = regexp.MustCompile(`\x1b\[[0-9;]+m`) +// ansiEscapeRegex matches SGR color escape sequences embedded in a line. It is +// compiled once at package load: getHighlights runs on every display refresh. +var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]+m`) // highlightLine applies visual/selection highlighting to a line. // The provided line might already have been highlighted by a user-provided @@ -22,26 +24,32 @@ func (e *Engine) highlightLine(line []rune, selection core.Selection) string { colors := e.getHighlights(line, sorted) var highlighted string - var highlightedSb strings.Builder // And apply highlighting before each rune. + var highlightedSb25 strings.Builder + for i, r := range line { if highlight, found := colors[i]; found { - highlightedSb.WriteString(string(highlight)) + highlightedSb25.WriteString(string(highlight)) } - highlightedSb.WriteRune(r) + highlightedSb25.WriteRune(r) } - highlighted += highlightedSb.String() + highlighted += highlightedSb25.String() - // Finally, highlight comments using a regex. + // Finally, highlight comments using a regex. The pattern only depends on + // the comment-begin option, so compile it lazily and reuse it until that + // option changes, rather than recompiling on every refresh. comment := strings.Trim(e.opts.GetString("comment-begin"), "\"") - commentPattern := fmt.Sprintf(`(^|\s)%s.*`, comment) + if comment != e.commentToken || e.commentRegex == nil { + e.commentToken = comment + e.commentRegex, _ = regexp.Compile(fmt.Sprintf(`(^|\s)%s.*`, comment)) + } - if commentsMatch, err := regexp.Compile(commentPattern); err == nil { + if e.commentRegex != nil { commentColor := color.SGRStart + color.Fg + "244" + color.SGREnd - highlighted = commentsMatch.ReplaceAllString(highlighted, fmt.Sprintf("%s${0}%s", commentColor, color.Reset)) + highlighted = e.commentRegex.ReplaceAllString(highlighted, fmt.Sprintf("%s${0}%s", commentColor, color.Reset)) } highlighted += color.Reset @@ -105,9 +113,7 @@ func (e *Engine) getHighlights(line []rune, sorted []core.Selection) map[int][]r // Find any highlighting already applied on the line, // and keep the indexes so that we can skip those. - var colors [][]int - - colors = rxColorEscape.FindAllStringIndex(string(line), -1) + colors := ansiEscapeRegex.FindAllStringIndex(string(line), -1) // marks that started highlighting, but not done yet. regions := make([]core.Selection, 0) @@ -216,14 +222,6 @@ func (e *Engine) hlReset(regions []core.Selection, line []rune, pos int) ([]core // foreground := e.opts.GetString("active-region-start-color") line = append(line, []rune(color.ReverseReset)...) line = append(line, []rune(color.BgDefault)...) - // if background == "" && foreground == "" && !matcher { - // line = append(line, []rune(color.ReverseReset)...) - // } else { - // - // line = append(line, []rune(color.BgDefault)...) - // } - // - // line = append(line, []rune(color.ReverseReset)...) } } diff --git a/readline/internal/display/hint_test.go b/readline/internal/display/hint_test.go new file mode 100644 index 0000000..29b11a3 --- /dev/null +++ b/readline/internal/display/hint_test.go @@ -0,0 +1,144 @@ +//go:build unix + +package display_test + +import ( + "strings" + "testing" +) + +// rowIndex returns the index of the first screen row containing substr, or -1. +func rowIndex(screen, substr string) int { + for i, row := range strings.Split(screen, "\n") { + if strings.Contains(row, substr) { + return i + } + } + + return -1 +} + +// TestHintProviderTracksLine checks that a registered passive hint provider is +// re-evaluated as the input changes: the provided lane echoes the current line. +func TestHintProviderTracksLine(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "PROMPT> ", + cols: 80, + rows: 24, + hintProvider: true, + }) + c.waitForScreen("PROMPT>") + + c.send("abc") + c.waitForScreen("HINT:abc") + + // As the line grows, the provided hint tracks it. + c.send("d") + c.waitForScreen("HINT:abcd") +} + +// TestHintProviderAboveTransient verifies lane precedence: the passive provider +// hint renders strictly above the transient (async status) hint. +func TestHintProviderAboveTransient(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "PROMPT> ", + cols: 80, + rows: 24, + hintProvider: true, + transient: "ASYNCMSG", + }) + c.waitForScreen("PROMPT>") + + c.send("abc") + screen := c.waitUntil(func(s string) bool { + return strings.Contains(s, "HINT:abc") && strings.Contains(s, "ASYNCMSG") + }) + + provided := rowIndex(screen, "HINT:abc") + transient := rowIndex(screen, "ASYNCMSG") + + if provided < 0 || transient < 0 { + t.Fatalf("both hints must be present (provided=%d transient=%d):\n%s", + provided, transient, screen) + } + + if provided >= transient { + t.Fatalf("provider hint (row %d) must render above transient hint (row %d):\n%s", + provided, transient, screen) + } +} + +// TestTransientSurvivesIsearchAndRendersAbove checks the two guarantees that +// make the transient lane useful for async status: entering incremental search +// (which owns the completion/text lane) must NOT clear the transient hint, and +// the transient hint must render above that completion-lane hint. +func TestTransientSurvivesIsearchAndRendersAbove(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "PROMPT> ", + cols: 80, + rows: 24, + transient: "ASYNCMSG", + }) + c.waitForScreen("ASYNCMSG") + + // Ctrl-R enters incremental search, which sets the completion/text lane. + c.send("\x12") + screen := c.waitForScreen("inc-search") + + // The transient hint must have survived the isearch taking over the + // completion lane. + transient := rowIndex(screen, "ASYNCMSG") + isearch := rowIndex(screen, "inc-search") + + if transient < 0 { + t.Fatalf("transient hint was lost when entering incremental search:\n%s", screen) + } + + if transient >= isearch { + t.Fatalf("transient hint (row %d) must render above the isearch hint (row %d):\n%s", + transient, isearch, screen) + } +} + +// TestAsyncTransientWhileIdle proves the async-refresh wake: a transient hint +// pushed from another goroutine while the shell is idle (blocked waiting for +// input) must appear WITHOUT any keystroke being sent. +func TestAsyncTransientWhileIdle(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "PROMPT> ", + cols: 80, + rows: 24, + asyncMS: 200, + }) + c.waitForScreen("PROMPT>") + + // Deliberately send NO input: the only thing that can make this appear is + // the wake repainting the idle loop. + c.waitForScreen("ASYNCPING") +} + +// TestAsyncRefreshKeepsLayout guards against the async repaint corrupting the +// render bookkeeping ("prints a mess / jumps around"): after an idle async +// repaint, the prompt must still render exactly once and accept aligned input. +func TestAsyncRefreshKeepsLayout(t *testing.T) { + c := startConsole(t, consoleConfig{ + prompt: "PROMPT> ", + cols: 80, + rows: 24, + asyncMS: 200, + }) + c.waitForScreen("ASYNCPING") + + // Now type after the async repaint and confirm the line is intact. + c.send("abc") + screen := c.waitForScreen("PROMPT> abc") + + if got := countLine(screen, "PROMPT>"); got != 1 { + t.Fatalf("prompt should render exactly once after async repaint, got %d:\n%s", got, screen) + } + + first := strings.TrimRight(strings.SplitN(screen, "\n", 2)[0], " ") + if first != "PROMPT> abc" { + t.Fatalf("input misaligned after async repaint:\n got: %q\n want: %q", first, "PROMPT> abc") + } +} diff --git a/readline/internal/display/pty_harness_test.go b/readline/internal/display/pty_harness_test.go new file mode 100644 index 0000000..defb23f --- /dev/null +++ b/readline/internal/display/pty_harness_test.go @@ -0,0 +1,456 @@ +//go:build unix + +package display_test + +// This file provides a PTY-backed, virtual-terminal test harness for the shell. +// +// Why a subprocess under a real PTY (instead of swapping os.Stdin/os.Stdout +// in-process)? The library writes raw escape sequences directly to os.Stdout +// and the internal/term package captures the stdout/stderr file handles in its +// init(), before any test could redirect them. Running the shell in a child +// process that is given the PTY as its real std{in,out,err} is therefore the +// faithful way to exercise the true byte stream a terminal would see. +// +// The harness feeds the child's output into a vt10x virtual terminal so tests +// can assert on the *rendered* screen, and it auto-responds to cursor-position +// (DSR "ESC[6n") queries so that GetCursorPos() does not block forever +// consuming our keystrokes (see internal/core/keys_unix.go). +// +// It lives as an external test package (display_test) so it can drive the full +// shell via the root readline package without an import cycle. + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/creack/pty" + "github.com/hinshun/vt10x" + + "github.com/chainreactors/tui/readline" +) + +const ( + childEnvVar = "READLINE_PTY_CHILD" + promptEnvVar = "READLINE_PTY_PROMPT" + prefillEnvVar = "READLINE_PTY_PREFILL" + noProbeEnvVar = "READLINE_PTY_NOPROBE" + hintProviderEnvVar = "READLINE_PTY_HINTPROVIDER" + transientEnvVar = "READLINE_PTY_TRANSIENT" + asyncMSEnvVar = "READLINE_PTY_ASYNC_MS" + asyncCompEnvVar = "READLINE_PTY_ASYNCCOMP" + autocompleteEnvVar = "READLINE_PTY_AUTOCOMPLETE" +) + +// TestMain lets this test binary double as the process-under-test: when the +// harness re-execs us with READLINE_PTY_CHILD=1, we run a minimal readline app +// instead of the test suite. +func TestMain(m *testing.M) { + if os.Getenv(childEnvVar) == "1" { + runPTYChild() + return + } + + os.Exit(m.Run()) +} + +// runPTYChild runs one Readline() round with a deterministic prompt, then +// prints the accepted line (or error) wrapped in markers the harness matches on. +func runPTYChild() { + // Optionally push the prompt down the screen first, so tests can place it + // at (or near) the bottom of the terminal window. The value is the number + // of blank lines to print before starting the shell. + if n, err := strconv.Atoi(os.Getenv(prefillEnvVar)); err == nil && n > 0 { + fmt.Fprint(os.Stdout, strings.Repeat("\r\n", n)) + } + + rl := readline.NewShell() + + if os.Getenv(noProbeEnvVar) == "1" { + rl.Config.Set("cursor-position-probe", false) + } + + // Register a passive hint provider that echoes the current line, so tests + // can observe the provided lane tracking input. + if os.Getenv(hintProviderEnvVar) == "1" { + rl.Hint.SetProvider(func(line []rune, _ int) []rune { + if len(line) == 0 { + return nil + } + + return []rune("HINT:" + string(line)) + }) + } + + // Seed a transient (async status) hint before reading, so tests can assert + // it renders and survives completion/isearch activity. + if msg := os.Getenv(transientEnvVar); msg != "" { + rl.Hint.SetTransient(msg) + } + + // Async completion: a completer whose result set grows when a background + // goroutine flips a flag and calls RefreshCompletions (no keystroke). Used + // to exercise in-place menu regeneration (#99). + if os.Getenv(asyncCompEnvVar) == "1" { + var extra int32 + + // Prefix-sharing values so the same completer works for both explicit + // menus (empty prefix) and as-you-type autocomplete (typing "alp"). The + // added value sorts FIRST, so the grid re-sorts on growth — exercising + // content-based (not positional) selection restore. + rl.Completer = func(_ []rune, _ int) readline.Completions { + values := []string{"alpha", "alpine"} + if atomic.LoadInt32(&extra) == 1 { + values = append(values, "alpaca") + } + + return readline.CompleteValues(values...) + } + + if ms, err := strconv.Atoi(os.Getenv(asyncMSEnvVar)); err == nil && ms > 0 { + go func() { + time.Sleep(time.Duration(ms) * time.Millisecond) + atomic.StoreInt32(&extra, 1) + rl.RefreshCompletions() + }() + } + } else if ms, err := strconv.Atoi(os.Getenv(asyncMSEnvVar)); err == nil && ms > 0 { + // Push transient hint(s) from another goroutine AFTER the read loop has + // started and is idle, to exercise the async-refresh wake (no keystroke). + // READLINE_PTY_ASYNC_REPEAT controls how many updates are pushed. + repeat := 1 + if n, err := strconv.Atoi(os.Getenv("READLINE_PTY_ASYNC_REPEAT")); err == nil && n > 0 { + repeat = n + } + + go func() { + for i := range repeat { + time.Sleep(time.Duration(ms) * time.Millisecond) + rl.Hint.SetTransient(fmt.Sprintf("ASYNCPING-%d", i)) + } + }() + } + + // As-you-type autocomplete. Composes with asyncComp (which sets a growing + // completer); otherwise installs a static completer (several values + a usage + // hint) to reproduce the hint+menu redraw path. + if os.Getenv(autocompleteEnvVar) == "1" { + rl.Config.Set("autocomplete", true) + + if rl.Completer == nil { + rl.Completer = func(_ []rune, _ int) readline.Completions { + return readline.CompleteValues("alpha", "alef", "alpine", "almond").Usage("pick a word") + } + } + } + + prompt := os.Getenv(promptEnvVar) + if prompt == "" { + prompt = "> " + } + + rl.Prompt.Primary(func() string { return prompt }) + + // Optional right-side prompt (like the example's clock): it is re-rendered + // every refresh and reaches the far-right column. + if rp := os.Getenv("READLINE_PTY_RIGHTPROMPT"); rp != "" { + rl.Prompt.Right(func() string { return rp }) + } + + line, err := rl.Readline() + if err != nil { + fmt.Fprintf(os.Stdout, "\r\n[ERR:%s]\r\n", err) + os.Exit(0) + } + + fmt.Fprintf(os.Stdout, "\r\n[LINE:%s]\r\n", line) + os.Exit(0) +} + +// console drives a child shell over a PTY and mirrors its output into a vt10x +// virtual terminal for screen assertions. +type console struct { + t *testing.T + cmd *exec.Cmd + ptmx *os.File + term vt10x.Terminal + + mu sync.Mutex // guards term (writer + reads), probeCount and DSR replies + done chan struct{} + probeCount int // number of "ESC[6n" cursor-position queries observed + + // probeReply, if non-nil, computes the DSR reply bytes for an "ESC[6n" + // cursor-position query, letting tests simulate a misbehaving terminal. + // If nil, the emulator's true cursor position is reported (1-based). + probeReply func(cur vt10x.Cursor) string +} + +// consoleConfig configures a PTY-backed test console. +type consoleConfig struct { + prompt string + cols, rows int + // prefill is the number of blank lines printed before the prompt, used to + // push the prompt down the window (e.g. to the bottom row). + prefill int + // noProbe disables the shell's cursor-position probing in the child. + noProbe bool + // hintProvider registers a passive hint provider in the child that echoes + // the current line as "HINT:". + hintProvider bool + // transient, if non-empty, seeds a transient (async status) hint in the + // child before the read loop starts. + transient string + // asyncMS, if > 0, makes the child push a transient hint ("ASYNCPING") + // from another goroutine after that many milliseconds, once idle. With + // asyncComp set, it instead grows the completer and calls + // RefreshCompletions after that delay. + asyncMS int + // asyncComp installs a completer whose results grow ("charlie" is added) + // when the async goroutine fires RefreshCompletions. + asyncComp bool + // asyncRepeat, with asyncMS > 0, pushes that many transient hint updates + // (each one an async-refresh wake), to detect drift across wake refreshes. + asyncRepeat int + // rightPrompt, if set, installs a right-side prompt reaching the far-right + // column (like the example console's clock). + rightPrompt string + // autocomplete turns on as-you-type autocompletion with a static completer + // (several values + a usage hint), to exercise the hint+menu redraw path. + autocomplete bool + // probeReply, if non-nil, computes the DSR reply for an "ESC[6n" query, + // letting tests simulate a terminal that reports a wrong cursor position. + probeReply func(vt10x.Cursor) string +} + +// newConsole spawns the child shell under a PTY of the given size, with the +// given primary prompt, and starts mirroring its output into the emulator. +func newConsole(t *testing.T, prompt string, cols, rows int) *console { + t.Helper() + + return startConsole(t, consoleConfig{prompt: prompt, cols: cols, rows: rows}) +} + +// startConsole spawns the child shell under a PTY using the full config and +// starts mirroring its output into the emulator. +func startConsole(t *testing.T, cfg consoleConfig) *console { + t.Helper() + + cmd := exec.CommandContext(context.Background(), os.Args[0]) + cmd.Env = append(os.Environ(), + childEnvVar+"=1", + promptEnvVar+"="+cfg.prompt, + fmt.Sprintf("%s=%d", prefillEnvVar, cfg.prefill), + "INPUTRC=/dev/null", // don't pick up a developer's ~/.inputrc + "TERM=xterm-256color", + ) + + if cfg.noProbe { + cmd.Env = append(cmd.Env, noProbeEnvVar+"=1") + } + + if cfg.hintProvider { + cmd.Env = append(cmd.Env, hintProviderEnvVar+"=1") + } + + if cfg.transient != "" { + cmd.Env = append(cmd.Env, transientEnvVar+"="+cfg.transient) + } + + if cfg.asyncMS > 0 { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", asyncMSEnvVar, cfg.asyncMS)) + } + + if cfg.asyncComp { + cmd.Env = append(cmd.Env, asyncCompEnvVar+"=1") + } + + if cfg.autocomplete { + cmd.Env = append(cmd.Env, autocompleteEnvVar+"=1") + } + + if cfg.asyncRepeat > 0 { + cmd.Env = append(cmd.Env, fmt.Sprintf("READLINE_PTY_ASYNC_REPEAT=%d", cfg.asyncRepeat)) + } + + if cfg.rightPrompt != "" { + cmd.Env = append(cmd.Env, "READLINE_PTY_RIGHTPROMPT="+cfg.rightPrompt) + } + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: uint16(cfg.rows), Cols: uint16(cfg.cols)}) + if err != nil { + t.Fatalf("start child under pty: %v", err) + } + + c := &console{ + t: t, + cmd: cmd, + ptmx: ptmx, + term: vt10x.New(vt10x.WithSize(cfg.cols, cfg.rows)), + done: make(chan struct{}), + probeReply: cfg.probeReply, + } + + go c.readLoop() + + t.Cleanup(c.close) + + return c +} + +// readLoop copies child output into the emulator and answers DSR queries. +func (c *console) readLoop() { + defer close(c.done) + + buf := make([]byte, 4096) + + for { + n, err := c.ptmx.Read(buf) + if n > 0 { + chunk := buf[:n] + + c.mu.Lock() + _, _ = c.term.Write(chunk) + c.mu.Unlock() + + // Respond to "ESC[6n" cursor-position reports. The prompt bytes + // preceding the query are already applied above, so the emulator's + // cursor reflects the true start-of-line column. + // + // NOTE: this assumes the query is not split across read boundaries, + // which holds in practice because the shell emits prompt+query in + // one write. A buffering scanner can be added if that changes. + if bytes.Contains(chunk, []byte("\x1b[6n")) { + c.mu.Lock() + c.probeCount++ + c.mu.Unlock() + + c.replyCursorPos() + } + } + + if err != nil { + return + } + } +} + +// replyCursorPos writes a DSR cursor-position report (1-based row;col), or the +// custom reply from probeReply when a test wants to simulate a bad terminal. +func (c *console) replyCursorPos() { + c.mu.Lock() + cur := c.term.Cursor() + fn := c.probeReply + c.mu.Unlock() + + reply := fmt.Sprintf("\x1b[%d;%dR", cur.Y+1, cur.X+1) + if fn != nil { + reply = fn(cur) + } + + if reply != "" { + _, _ = c.ptmx.WriteString(reply) + } +} + +// send writes raw bytes to the child as if typed by the user. +func (c *console) send(s string) { + c.t.Helper() + + if _, err := c.ptmx.WriteString(s); err != nil { + c.t.Fatalf("send %q: %v", s, err) + } +} + +// probeQueries returns how many "ESC[6n" cursor-position queries the child has +// sent so far. +func (c *console) probeQueries() int { + c.mu.Lock() + defer c.mu.Unlock() + + return c.probeCount +} + +// screen returns the current rendered contents of the virtual terminal. +func (c *console) screen() string { + c.mu.Lock() + defer c.mu.Unlock() + + return c.term.String() +} + +// screenWaitTimeout bounds how long the screen-polling helpers wait before +// failing the test. Every call site used the same value, so it lives here. +// +// The pollers return the instant the awaited content appears, so this ceiling +// never slows a passing test -- it only caps how long we wait before declaring +// failure. It is deliberately generous: under `go test -race` on a loaded CI +// runner the whole PTY/render pipeline is starved (race instrumentation adds +// several-fold overhead), and a tight bound turns that into spurious timeouts +// for the async-refresh tests (locally they settle in well under 2s). +const screenWaitTimeout = 15 * time.Second + +// waitForScreen polls until the rendered screen contains substr, or fails the +// test on timeout. It returns the (last) screen contents either way. +func (c *console) waitForScreen(substr string) string { + c.t.Helper() + + deadline := time.Now().Add(screenWaitTimeout) + + for { + s := c.screen() + if strings.Contains(s, substr) { + return s + } + + if time.Now().After(deadline) { + c.t.Fatalf("timed out waiting for %q on screen; got:\n%s", substr, s) + return s + } + + time.Sleep(10 * time.Millisecond) + } +} + +// waitUntil polls the rendered screen until cond returns true, or fails the +// test on timeout. Returns the last screen contents. +func (c *console) waitUntil(cond func(screen string) bool) string { + c.t.Helper() + + deadline := time.Now().Add(screenWaitTimeout) + + for { + s := c.screen() + if cond(s) { + return s + } + + if time.Now().After(deadline) { + c.t.Fatalf("timed out waiting for screen condition; got:\n%s", s) + return s + } + + time.Sleep(10 * time.Millisecond) + } +} + +// close tears the child and PTY down. Registered via t.Cleanup, so it must +// never hang even when a test fails mid-flight: we close the PTY (which EOFs +// the child's reads), then force-kill as a backstop before reaping. +func (c *console) close() { + _ = c.ptmx.Close() + + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } + + _ = c.cmd.Wait() + <-c.done +} diff --git a/readline/internal/display/refresh.go b/readline/internal/display/refresh.go new file mode 100644 index 0000000..bd2ba2d --- /dev/null +++ b/readline/internal/display/refresh.go @@ -0,0 +1,341 @@ +package display + +import ( + "fmt" + "strconv" + + "github.com/chainreactors/tui/readline/internal/color" + "github.com/chainreactors/tui/readline/internal/completion" + "github.com/chainreactors/tui/readline/internal/core" + "github.com/chainreactors/tui/readline/internal/strutil" + "github.com/chainreactors/tui/readline/internal/term" + "github.com/chainreactors/tui/readline/internal/ui" +) + +// Refresh recomputes and redisplays the entire readline interface, except +// the first lines of the primary prompt when the latter is a multiline one. +func (e *Engine) Refresh() { + // Buffer the whole frame and flush once, so the terminal never shows a + // partial repaint and we issue a single write instead of dozens. + term.BeginBuffer() + defer term.EndBuffer() + + // 1. Preparation & Coordinates + term.WriteString(term.HideCursor) + // Go back to the first column, and if the primary prompt + // was not printed yet, back up to the line's beginning row. + term.MoveCursorBackwards(term.GetWidth()) + + if !e.primaryPrinted { + term.MoveCursorUp(e.cursorRow) + } + // 2. Primary Prompt + e.prompt.LastPrint() + // Compute Coordinates: StartPos, LineHeight, CursorPos (row/col). + e.computeCoordinates(true) + + // Ensure that the indicator is printed if the prompt is empty, + // and that we have enough space to print the line. + e.ensureIndicatorSpace() + + // Recompute coordinates with the new indentation/cursor position. + if e.line.Lines() > 0 { + e.cursorCol, e.cursorRow = core.CoordinatesCursor(e.cursor, e.startCols) + e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols) + } + + // Ensure that we have enough space to print the line. + // We probe the terminal to verify that we are not at the bottom of the screen. + // If we are, we scroll the screen to make space for the line. + e.ensureInputSpace() + + // Keep a multi-line prompt's upper lines correct now that the start row is + // settled: they are printed only once and not otherwise refreshed, so a + // scroll (typically at the bottom of the window) would leave them stale. + e.repaintPromptUpperLines() + + e.completer.Autocomplete() + + // 3. Input Area Rendering + e.renderInputArea() + + // 4. Helpers Rendering + // We clear everything below the input area to ensure that no artifacts + // from previous renders (like longer lines or helpers) remain visible. + // + // We need to move one row below the input, clear everything there, and + // come back. However, CUD (\x1b[1B) is a no-op on the last terminal + // row, so we check whether we're already at the bottom. If we are, + // there's nothing below to clear and we can skip. If we're not, we use + // CUD + clear + CUU to clean up artifacts from previous renders. + termHeight := term.GetLength() + atBottom := (e.startRows + e.lineRows) >= termHeight + + if !atBottom { + term.MoveCursorDown(1) + term.MoveCursorBackwards(term.GetWidth()) + term.WriteString(term.ClearScreenBelow) + term.MoveCursorUp(1) + term.MoveCursorForwards(e.lineCol) + } + + e.renderHelpers() + + // 5. Final Cursor Positioning + // The cursor is currently at the end of the input line (lineRows, lineCol). + // We need to move it to the actual cursor position (cursorRow, cursorCol). + if e.lineRows > e.cursorRow { + term.MoveCursorUp(e.lineRows - e.cursorRow) + } + + term.MoveCursorBackwards(term.GetWidth()) + term.MoveCursorForwards(e.cursorCol) + + term.WriteString(term.ShowCursor) +} + +// repaintPromptUpperLines reprints the upper lines of a multi-line prompt at +// the (now settled) start row. Those lines are printed only once initially and +// are not otherwise refreshed, so when the view scrolls -- typically when the +// prompt sits at the bottom of the window -- they would be left stale or +// overwritten (issue #98 / reeflective/console#78). The cursor is at the +// input-line start on entry and is restored there on return. +func (e *Engine) repaintPromptUpperLines() { + rows := e.prompt.PrimaryUsed() + if rows == 0 { + return + } + + // Go to the first prompt row at column 0, repaint the upper lines (each + // ends in a newline, leaving us at column 0 of the last prompt-line row), + // then restore the cursor to the input-line start. + term.MoveCursorBackwards(term.GetWidth()) + term.MoveCursorUp(rows) + e.prompt.UpperPrint() + term.MoveCursorForwards(e.startCols) +} + +func (e *Engine) renderInputArea() { + e.displayLineRefactored() + e.renderMultilineIndicators() + e.renderRightPrompt() +} + +func (e *Engine) renderHelpers() { + // 1. Check if we have anything to print. + hintRows := ui.CoordinatesHint(e.hint) + compMatches := e.completer.Matches() + compSkip := e.completer.DisplaySkipped() + + // Refresh() already cleared below the input line before calling us, + // so no additional clear is needed here. + + if hintRows == 0 && (compMatches == 0 || compSkip) { + e.hintRows = 0 + e.compRows = 0 + + return + } + + term.WriteString(term.NewlineReturn) + + // 3. Display Hints + ui.DisplayHint(e.hint) + e.hintRows = ui.CoordinatesHint(e.hint) + + // 4. Display Completions + if compMatches > 0 && !compSkip { + completion.Display(e.completer, e.AvailableHelperLines()) + e.compRows = completion.Coordinates(e.completer) + } else { + e.completer.ResetUsedRows() + e.compRows = 0 + } + + // 5. Restore Cursor to the "bottom of input area" + // The cursor is currently at the bottom of the helpers. + // We need to move it back up to the line just below the input text. + term.MoveCursorUp(e.compRows) + term.MoveCursorUp(e.hintRows) + term.MoveCursorUp(1) + + // We are now on the same row as the end of the input line, + // but at column 0. We need to move to e.lineCol. + term.MoveCursorForwards(e.lineCol) +} + +func (e *Engine) renderRightPrompt() { + e.prompt.RightPrint(e.lineCol, true) + + // Restore cursor to the end of the input line. + term.MoveCursorBackwards(term.GetWidth()) + term.MoveCursorForwards(e.lineCol) +} + +func (e *Engine) ensureIndicatorSpace() { + // Determine the width of the multiline indicator. + // We need to ensure that the indentation of the input line is at least + // as wide as the indicator, otherwise the indicator will overwrite the text + // on subsequent lines. + var indicatorWidth int + if e.opts.GetBool("multiline-column-numbered") { + indicatorWidth = len(strconv.Itoa(1)) + 1 + } else { + indicatorWidth = 2 + } + + // Adjust indentation if the primary prompt is empty, + // because we will print a column indicator on the first line. + if e.prompt.LastUsed() == 0 && e.line.Lines() > 0 { + var indicator string + if e.opts.GetBool("multiline-column-numbered") { + indicator = fmt.Sprintf(color.FgBlackBright+"%d"+color.Reset+" ", 1) + } else { + indicator = ui.DefaultMultilineColumn + } + + e.startCols += indicatorWidth + // Print the indicator on the first line. + term.WriteString(indicator) + } else if e.line.Lines() > 0 && e.startCols < indicatorWidth { + // If the prompt is shorter than the indicator, pad with spaces + // to ensure the input text starts aligned with subsequent lines + // and isn't overwritten by the indicator. + padding := indicatorWidth - e.startCols + term.Printf("%*s", padding, "") + + e.startCols = indicatorWidth + } +} + +func (e *Engine) ensureInputSpace() { + // The input area occupies lineRows+1 visual rows starting at startRows, and + // the redraw/helper machinery always steps one further row below it (e.g. the + // "move down 1, clear below, move up 1" sequences). When the prompt is + // rendered at the bottom of the window that trailing row does not exist: the + // terminal clamps our downward moves while the paired upward moves still + // travel, so the row bookkeeping drifts and the prompt's lines get + // overwritten/overlapped (issue #98, reeflective/console#78). + // + // startRows was just probed in computeCoordinates, so we can tell purely from + // it whether the area plus its trailing row runs past the bottom, and scroll + // the screen up by exactly the missing rows (adjusting startRows to match) + // without issuing another cursor-position query. + // Reserving space requires the cursor's absolute row, which only the + // cursor-position probe provides. When probing is disabled or unavailable + // (startRows < 1), we cannot detect the bottom of the window, so we skip + // this step -- the documented degraded behavior is that a prompt at the very + // bottom may overlap (see disable-cursor-position-probe). + if e.startRows < 1 { + return + } + + reserve := e.lineRows + 1 + + deficit := (e.startRows + reserve) - term.GetLength() + if deficit <= 0 { + return + } + + // We are at the input-line start. Drop to the bottom of the input area + // (clamped at the last row), emit newlines to scroll the screen up by the + // deficit, then climb back to the new input-line start. + term.MoveCursorDown(e.lineRows) + + for range deficit { + term.WriteString(term.NewlineReturn) + } + + e.startRows -= deficit + + term.MoveCursorUp(reserve) + term.MoveCursorForwards(e.startCols) +} + +func (e *Engine) displayLineRefactored() { + var line string + // Apply user-defined highlighter to the input line. + if e.highlighter != nil { + line = e.highlighter(*e.line) + } else { + line = string(*e.line) + } + // Highlight matching parenthesis + if e.opts.GetBool("blink-matching-paren") { + core.HighlightMatchers(e.selection) + defer core.ResetMatchers(e.selection) + } + // Apply visual selections highlighting if any + line = e.highlightLine([]rune(line), *e.selection) + suggestionAdded := false + // Get the subset of the suggested line to print. + if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") { + line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset + suggestionAdded = true + } + + currentLine := string(*e.line) + if !suggestionAdded && e.inlineSuggestionApplies(currentLine) { + suffix := e.inlineSuggestion[len(currentLine):] + line += color.Dim + color.Fmt(color.Fg+"242") + suffix + color.Reset + } + // Format tabs as spaces, for consistent display + line = strutil.FormatTabs(line) + term.ClearLineAfter + // And display the line. + e.suggested.Set([]rune(line)...) + core.DisplayLine(&e.suggested, e.startCols) +} + +func (e *Engine) renderMultilineIndicators() { + // Check if we have multiple lines to manage. + if e.line.Lines() == 0 { + return + } + + // 1. Determine if we need to print columns. + columns := e.opts.GetBool("multiline-column") || + e.opts.GetBool("multiline-column-numbered") || + e.opts.GetString("multiline-column-custom") != "" + promptEmpty := e.prompt.LastUsed() == 0 + + // If no columns are requested and the prompt is not empty, we have nothing to do. + if !columns && !promptEmpty { + return + } + + // 2. Move to the top of the input area (first line). + term.MoveCursorUp(e.lineRows) + term.MoveCursorBackwards(term.GetWidth()) + + // 3. Print the indicators for subsequent lines (1..N). + printedLines := 0 + numbered := e.opts.GetBool("multiline-column-numbered") + + // Indicators + pipe := ui.DefaultMultilineColumn + + for i := 1; i <= e.line.Lines(); i++ { + term.WriteString("\n") + + switch { + case numbered: + term.Printf(color.FgBlackBright+"%d"+color.Reset+" ", i+1) + case i == e.line.Lines(): + e.prompt.SecondaryPrint() + default: + term.WriteString(pipe) + } + + printedLines++ + } + + // 4. Return cursor to the bottom of the input area. + correction := e.lineRows - printedLines + if correction > 0 { + term.MoveCursorDown(correction) + } + + // 5. Restore horizontal position to the end of the input text. + term.MoveCursorBackwards(term.GetWidth()) + term.MoveCursorForwards(e.lineCol) +} diff --git a/readline/internal/display/render_test.go b/readline/internal/display/render_test.go new file mode 100644 index 0000000..bf27702 --- /dev/null +++ b/readline/internal/display/render_test.go @@ -0,0 +1,194 @@ +//go:build unix + +package display_test + +import ( + "strings" + "testing" + "time" + + "github.com/hinshun/vt10x" +) + +// countLine reports how many of the screen's rows begin with prefix. +func countLine(screen, prefix string) int { + var n int + + for _, row := range strings.Split(screen, "\n") { + if strings.HasPrefix(row, prefix) { + n++ + } + } + + return n +} + +// TestRenderPromptAndInput is the baseline end-to-end golden-screen test: boot +// a shell under a PTY, check the prompt renders, type a line, check it appears +// next to the prompt, then submit it and check it is accepted and returned. +func TestRenderPromptAndInput(t *testing.T) { + c := newConsole(t, "PROMPT> ", 80, 24) + + c.waitForScreen("PROMPT>") + + c.send("hello world") + screen := c.waitForScreen("PROMPT> hello world") + + firstLine := strings.SplitN(screen, "\n", 2)[0] + if got, want := strings.TrimRight(firstLine, " "), "PROMPT> hello world"; got != want { + t.Fatalf("first line misaligned:\n got: %q\n want: %q", got, want) + } + + c.send("\r") + c.waitForScreen("[LINE:hello world]") +} + +// TestRenderWrappingAlignment guards multi-row layout: with a well-behaved +// terminal, a line that wraps onto a second row renders with the prompt exactly +// once and the input flush against it. +func TestRenderWrappingAlignment(t *testing.T) { + // 40-col terminal, 8-col prompt -> 32 text columns on the first row. + c := newConsole(t, "PROMPT> ", 40, 24) + c.waitForScreen("PROMPT>") + + // Type 40 'x': 32 land on row 0 (after the prompt), 8 wrap to row 1. + c.send(strings.Repeat("x", 40)) + screen := c.waitUntil(func(s string) bool { + return strings.Count(s, "x") >= 40 + }) + + rows := strings.Split(screen, "\n") + + if got := countLine(screen, "PROMPT>"); got != 1 { + t.Fatalf("prompt should render exactly once, got %d times:\n%s", got, screen) + } + + if want := "PROMPT> " + strings.Repeat("x", 32); strings.TrimRight(rows[0], " ") != want { + t.Fatalf("row 0 wrong:\n got: %q\n want: %q", strings.TrimRight(rows[0], " "), want) + } + + if want := strings.Repeat("x", 8); strings.TrimRight(rows[1], " ") != want { + t.Fatalf("row 1 (wrapped continuation) wrong:\n got: %q\n want: %q", + strings.TrimRight(rows[1], " "), want) + } +} + +// TestRenderMultilinePromptAtBottom guards the bottom-of-window case: a +// multi-line primary prompt rendered on the last row of the terminal must keep +// all of its lines (including the input line) instead of overlapping them. +// +// Before ensureInputSpace reserved the prompt+input height, the terminal +// scrolled underneath the prompt, the row bookkeeping drifted, and the prompt's +// last (input) line was pushed off the bottom and lost. +func TestRenderMultilinePromptAtBottom(t *testing.T) { + const rows = 10 + + // A 4-line prompt whose last line is the input line, placed on the very + // last row of the window (prefill pushes it down). The defect showed on the + // initial render, before any input. + c := startConsole(t, consoleConfig{ + prompt: "L1\nL2\nL3\nL4> ", + cols: 20, + rows: rows, + prefill: rows - 1, + }) + + c.waitForScreen("L1") + time.Sleep(250 * time.Millisecond) // let the render settle + + screen := c.screen() + + // Every line of the prompt — crucially the last/input line — must survive. + for _, line := range []string{"L1", "L2", "L3", "L4>"} { + if !strings.Contains(screen, line) { + t.Fatalf("prompt line %q was lost when rendering at the bottom of the window:\n%s", + line, screen) + } + } +} + +// TestRenderMultilinePromptWrapAtBottom guards the harder compound case: a +// multi-line prompt at the bottom of the window whose input also wraps onto +// extra rows. The prompt's upper lines (printed once, not otherwise refreshed) +// must survive the scrolling instead of being lost. +func TestRenderMultilinePromptWrapAtBottom(t *testing.T) { + const rows = 10 + + c := startConsole(t, consoleConfig{ + prompt: "TOP\nP> ", + cols: 20, + rows: rows, + prefill: rows - 1, + }) + + c.waitForScreen("P>") + + // Type enough to wrap the input onto a second/third visual row. + c.send(strings.Repeat("y", 50)) + screen := c.waitUntil(func(s string) bool { + return strings.Count(s, "y") >= 50 + }) + + for _, line := range []string{"TOP", "P> "} { + if !strings.Contains(screen, line) { + t.Fatalf("prompt line %q was lost when a multi-line prompt wrapped at the bottom:\n%s", + line, screen) + } + } +} + +// TestCursorProbeEnabledByDefault confirms probing is on by default: a normal +// render issues at least one "ESC[6n" cursor-position query. +func TestCursorProbeEnabledByDefault(t *testing.T) { + c := newConsole(t, "PROMPT> ", 80, 24) + c.waitForScreen("PROMPT>") + + // The prompt string is printed before the first probe, so poll for the + // query rather than checking immediately after the prompt appears. + deadline := time.Now().Add(3 * time.Second) + for c.probeQueries() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + + if c.probeQueries() == 0 { + t.Fatal("expected at least one ESC[6n cursor-position query with probing enabled") + } +} + +// TestRenderWithCursorProbeDisabled verifies the cursor-position-probe option: +// when turned off, the shell sends no "ESC[6n" query and renders correctly from +// the printed prompt width alone — even against a terminal that would have lied +// about the cursor position. This is the supported fallback for PTY harnesses +// and constrained terminals (#101). +func TestRenderWithCursorProbeDisabled(t *testing.T) { + // This responder would report a bogus column; with probing disabled it must + // never be consulted. + lying := func(vt10x.Cursor) string { return "\x1b[1;1R" } + + c := startConsole(t, consoleConfig{ + prompt: "PROMPT> ", + cols: 40, + rows: 24, + noProbe: true, + probeReply: lying, + }) + c.waitForScreen("PROMPT>") + + c.send(strings.Repeat("x", 40)) // wrap to a second row + screen := c.waitUntil(func(s string) bool { + return strings.Count(s, "x") >= 40 + }) + + if got := c.probeQueries(); got != 0 { + t.Fatalf("expected no ESC[6n queries when probing is disabled, got %d", got) + } + + if got := countLine(screen, "PROMPT>"); got != 1 { + t.Fatalf("prompt should render exactly once with probing disabled, got %d:\n%s", got, screen) + } + + row0 := strings.TrimRight(strings.SplitN(screen, "\n", 2)[0], " ") + if want := "PROMPT> " + strings.Repeat("x", 32); row0 != want { + t.Fatalf("row 0 misaligned with probing disabled:\n got: %q\n want: %q", row0, want) + } +} diff --git a/readline/internal/editor/buffers.go b/readline/internal/editor/buffers.go index 5be2bcf..1378d0f 100644 --- a/readline/internal/editor/buffers.go +++ b/readline/internal/editor/buffers.go @@ -197,11 +197,13 @@ func (reg *Buffers) Reset() { // Complete returns the contents of all buffers as a structured list of completions. func (reg *Buffers) Complete() completion.Values { - vals := make([]completion.Candidate, 0) - // Alpha and numbered registers - vals = append(vals, reg.completeNumRegs()...) - vals = append(vals, reg.completeAlphaRegs()...) + numRegs := reg.completeNumRegs() + alphaRegs := reg.completeAlphaRegs() + + vals := make([]completion.Candidate, 0, len(numRegs)+len(alphaRegs)) + vals = append(vals, numRegs...) + vals = append(vals, alphaRegs...) // Disable sorting, force list long and add hint. comps := completion.AddRaw(vals) @@ -279,16 +281,17 @@ func (reg *Buffers) writeAlpha(register rune, buf []rune) { } func (reg *Buffers) completeNumRegs() []completion.Candidate { - regs := make([]completion.Candidate, 0) tag := color.Dim + "num ([0-9])" + color.Reset - var nums []int + nums := make([]int, 0, len(reg.num)) for reg := range reg.num { nums = append(nums, reg) } sort.Ints(nums) + regs := make([]completion.Candidate, 0, len(nums)) + for _, num := range nums { buf := reg.num[num] display := strings.ReplaceAll(string(buf), "\n", ` `) @@ -306,16 +309,17 @@ func (reg *Buffers) completeNumRegs() []completion.Candidate { } func (reg *Buffers) completeAlphaRegs() []completion.Candidate { - regs := make([]completion.Candidate, 0) tag := color.Dim + "alpha ([a-z], [A-Z])" + color.Reset - var lett []rune + lett := make([]rune, 0, len(reg.alpha)) for slot := range reg.alpha { lett = append(lett, slot) } sort.Slice(lett, func(i, j int) bool { return i < j }) + regs := make([]completion.Candidate, 0, len(lett)) + for _, letter := range lett { buf := reg.alpha[letter] display := strings.ReplaceAll(string(buf), "\n", ` `) diff --git a/readline/internal/editor/editor.go b/readline/internal/editor/editor.go index 2e23d04..981d1c3 100644 --- a/readline/internal/editor/editor.go +++ b/readline/internal/editor/editor.go @@ -4,7 +4,7 @@ package editor import ( - "crypto/md5" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -44,7 +44,8 @@ func writeToFile(buf []byte, filename string) (string, error) { if filename == "" { fileID := strconv.Itoa(time.Now().Nanosecond()) + ":" + string(buf) - h := md5.New() + // Not security-sensitive: only used to derive a unique temp-file name. + h := sha256.New() _, err := h.Write([]byte(fileID)) if err != nil { diff --git a/readline/internal/editor/editor_unix.go b/readline/internal/editor/editor_unix.go index a5cb2fc..84c11b1 100644 --- a/readline/internal/editor/editor_unix.go +++ b/readline/internal/editor/editor_unix.go @@ -3,6 +3,7 @@ package editor import ( + "context" "errors" "fmt" "os" @@ -27,12 +28,14 @@ func (reg *Buffers) EditBuffer(buf []rune, filename, filetype string, emacs bool args := []string{} if filetype != "" { - args = append(args, fmt.Sprintf("-c 'set filetype=%s", filetype)) + args = append(args, "-c 'set filetype="+filetype) } args = append(args, name) - cmd := exec.Command(editor, args...) + // context.Background(): EditBuffer has no caller-supplied context to thread, + // and the editor runs for as long as the user keeps it open. + cmd := exec.CommandContext(context.Background(), editor, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/readline/internal/history/file.go b/readline/internal/history/file.go index 58a884e..ae6ae29 100644 --- a/readline/internal/history/file.go +++ b/readline/internal/history/file.go @@ -30,6 +30,8 @@ type Item struct { } // NewSourceFromFile returns a new history source writing to and reading from a file. +// +//nolint:ireturn // Returns the Source interface by design: the concrete type is unexported. func NewSourceFromFile(file string) (Source, error) { var err error diff --git a/readline/internal/history/history.go b/readline/internal/history/history.go index 7220f54..b33d599 100644 --- a/readline/internal/history/history.go +++ b/readline/internal/history/history.go @@ -7,10 +7,10 @@ var defaultSourceName = "default history" // logs the history to memory ([]string to be precise). type Source interface { // Append takes the line and returns an updated number of lines or an error - Write(string) (int, error) + Write(line string) (int, error) // GetLine takes the historic line number and returns the line or an error - GetLine(int) (string, error) + GetLine(pos int) (string, error) // Len returns the number of history lines Len() int @@ -31,6 +31,8 @@ type memory struct { } // NewInMemoryHistory creates a new in-memory command history source. +// +//nolint:ireturn // Returns the Source interface by design: the concrete type is unexported. func NewInMemoryHistory() Source { return new(memory) } diff --git a/readline/internal/history/sources.go b/readline/internal/history/sources.go index e45dc10..0481eb9 100644 --- a/readline/internal/history/sources.go +++ b/readline/internal/history/sources.go @@ -280,6 +280,8 @@ func (h *Sources) OnLastSource() bool { } // Current returns the current/active history source. +// +//nolint:ireturn // Returns the Source interface by design: sources are stored as the interface. func (h *Sources) Current() Source { if len(h.list) == 0 { return nil @@ -308,9 +310,14 @@ func (h *Sources) Write(infer bool) { continue } - // Don't write it if the history source has reached - // the maximum number of lines allowed (inputrc) - if h.maxEntries == 0 || h.maxEntries >= history.Len() { + // Don't write it if the history source has reached the maximum + // number of lines allowed (inputrc history-size). maxEntries <= 0 + // means unlimited (the in-memory default is -1); a positive cap + // stops appending once the source is full. The previous condition + // was inverted -- it skipped every write while the source was still + // under the cap, so any configured history-size disabled writing + // entirely. + if h.maxEntries > 0 && history.Len() >= h.maxEntries { continue } @@ -484,8 +491,13 @@ func Complete(h *Sources, forward, filter bool, maxLines int, regex *regexp.Rege h.hint.Set(color.Bold + color.FgCyanBright + h.names[h.sourcePos] + color.Reset) - compLines := make([]completion.Candidate, 0) - printedLines := make([]string, 0) + capHint := history.Len() + if maxLines >= 0 && maxLines < capHint { + capHint = maxLines + 1 + } + + compLines := make([]completion.Candidate, 0, capHint) + seen := make(map[string]bool, capHint) // Set up iteration clauses var ( @@ -523,17 +535,14 @@ func Complete(h *Sources, forward, filter bool, maxLines int, regex *regexp.Rege continue } - // If this history line is a duplicate of an existing one, - // remove the existing one and keep this one as it's more recent. - if yes, pos := contains(printedLines, line); yes { - printedLines = append(printedLines[:pos], printedLines[pos+1:]...) - printedLines = append(printedLines, line) - + // Skip lines already emitted. The first occurrence in scan order (the + // most recent line, for a reverse scan) is the one kept, matching the + // previous linear-dedup behavior but in O(1) instead of O(n) per line. + if seen[line] { continue } - // Add to the list of printed lines if we have a new one. - printedLines = append(printedLines, line) + seen[line] = true display := strings.ReplaceAll(line, "\n", ` `) @@ -594,6 +603,13 @@ func (h *Sources) match(match *core.Line, cur *core.Cursor, usePos, fwd, regex b histPos = history.Len() - h.hpos } + // The string to match against is invariant across the whole scan, so + // compute it once here instead of rebuilding it for every history entry. + cline := string(*match) + if cur != nil && cur.Pos() < match.Len() { + cline = cline[:cur.Pos()] + } + for done(histPos) { // Fetch the next/prev line and adapt its length. histPos = move(histPos) @@ -603,21 +619,12 @@ func (h *Sources) match(match *core.Line, cur *core.Cursor, usePos, fwd, regex b return line, pos, found } - cline := string(*match) - if cur != nil && cur.Pos() < match.Len() { - cline = cline[:cur.Pos()] - } - // Matching: either as substring (regex) or since beginning. switch { case regex: - regexLine, err := regexp.Compile(regexp.QuoteMeta(cline)) - if err != nil { - continue - } - - // Go to next line if not matching as a substring. - if !regexLine.MatchString(histline) { + // regexp.QuoteMeta + MatchString is exactly a literal substring + // test, so match directly instead of compiling a regex per entry. + if !strings.Contains(histline, cline) { continue } @@ -688,25 +695,3 @@ func (h *Sources) setLineCursorMatch(next string) { h.cursor.Set(h.line.Len()) } } - -func contains(s []string, e string) (bool, int) { - for i, a := range s { - if a == e { - return true, i - } - } - - return false, 0 -} - -func removeDuplicates(source []string) []string { - list := []string{} - - for _, item := range source { - if no, _ := contains(list, item); no { - list = append(list, item) - } - } - - return list -} diff --git a/readline/internal/history/sources_test.go b/readline/internal/history/sources_test.go new file mode 100644 index 0000000..ed6958c --- /dev/null +++ b/readline/internal/history/sources_test.go @@ -0,0 +1,85 @@ +package history + +import ( + "testing" + + "github.com/chainreactors/tui/readline/internal/core" +) + +// newTestSources builds a minimal Sources backed by a single in-memory source, +// enough to exercise Write's cap logic without a full shell. +func newTestSources(maxEntries int) (*Sources, *memory, *core.Line) { + line := new(core.Line) + mem := new(memory) + + src := &Sources{ + line: line, + list: map[string]Source{defaultSourceName: mem}, + names: []string{defaultSourceName}, + maxEntries: maxEntries, + } + + return src, mem, line +} + +// writeLine sets the working line and writes it to history. +func writeLine(src *Sources, line *core.Line, text string) { + *line = core.Line([]rune(text)) + + src.Write(false) +} + +// TestWriteUnlimitedPersists guards the default (history-size unset -> -1): +// every distinct line must be written. +func TestWriteUnlimitedPersists(t *testing.T) { + src, mem, line := newTestSources(-1) + + writeLine(src, line, "one") + writeLine(src, line, "two") + writeLine(src, line, "three") + + if mem.Len() != 3 { + t.Fatalf("unlimited history wrote %d lines, want 3", mem.Len()) + } +} + +// TestWriteUnderCapPersists is the direct regression guard for the inverted +// condition: with a positive cap and the source still under it, writes MUST +// land. The old `maxEntries >= Len()` skipped them all. +func TestWriteUnderCapPersists(t *testing.T) { + src, mem, line := newTestSources(5) + + writeLine(src, line, "one") + writeLine(src, line, "two") + + if mem.Len() != 2 { + t.Fatalf("capped history under the limit wrote %d lines, want 2 (inverted-condition regression)", mem.Len()) + } +} + +// TestWriteStopsAtCap verifies the cap is actually enforced: once the source +// holds maxEntries lines, further distinct lines are not appended. +func TestWriteStopsAtCap(t *testing.T) { + src, mem, line := newTestSources(2) + + writeLine(src, line, "one") + writeLine(src, line, "two") + writeLine(src, line, "three") // should be refused: already at cap + + if mem.Len() != 2 { + t.Fatalf("history grew to %d lines past its cap of 2", mem.Len()) + } +} + +// TestWriteZeroMaxIsUnlimited documents that maxEntries == 0 is treated as +// unlimited (writes happen), matching the `> 0` guard. +func TestWriteZeroMaxIsUnlimited(t *testing.T) { + src, mem, line := newTestSources(0) + + writeLine(src, line, "one") + writeLine(src, line, "two") + + if mem.Len() != 2 { + t.Fatalf("maxEntries==0 wrote %d lines, want 2 (should be unlimited)", mem.Len()) + } +} diff --git a/readline/internal/keymap/completion.go b/readline/internal/keymap/completion.go index 1f0c024..4033feb 100644 --- a/readline/internal/keymap/completion.go +++ b/readline/internal/keymap/completion.go @@ -1,7 +1,7 @@ package keymap import ( - "golang.org/x/exp/slices" + "slices" "github.com/chainreactors/tui/readline/inputrc" ) diff --git a/readline/internal/keymap/config.go b/readline/internal/keymap/config.go index 71e4662..582f1da 100644 --- a/readline/internal/keymap/config.go +++ b/readline/internal/keymap/config.go @@ -26,6 +26,13 @@ var readlineOptions = map[string]interface{}{ "history-autosuggest": false, "multiline-column": true, "multiline-column-numbered": false, + + // Terminal + // cursor-position-probe controls whether the display engine queries the + // terminal for the cursor position ("ESC[6n"). Disable it in environments + // that don't reliably answer (PTY test harnesses, minimal emulators, + // constrained CI); the engine then falls back to the printed prompt width. + "cursor-position-probe": true, } // ReloadConfig parses all valid .inputrc configurations and immediately @@ -41,16 +48,17 @@ func (m *Engine) ReloadConfig(opts ...inputrc.Option) (err error) { // // This library implements various additional commands and keymaps. // Parse the configuration with a specific App name, ignoring errors. - inputrc.UserDefault(user, m.config, inputrc.WithApp("go")) + _ = inputrc.UserDefault(user, m.config, inputrc.WithApp("go")) // Parse user configurations. // // Those default settings are the base options often needed // by /etc/inputrc on various Linux distros (for special keys). - defaults := []inputrc.Option{ + defaults := make([]inputrc.Option, 0, 2+len(opts)) + defaults = append(defaults, inputrc.WithMode("emacs"), inputrc.WithTerm(os.Getenv("TERM")), - } + ) opts = append(defaults, opts...) @@ -83,7 +91,7 @@ func (m *Engine) ReloadConfig(opts ...inputrc.Option) (err error) { func (m *Engine) loadBuiltinOptions() { for name, value := range readlineOptions { if val := m.config.Get(name); val == nil { - m.config.Set(name, value) + _ = m.config.Set(name, value) } } } @@ -143,7 +151,7 @@ func printBindsReadable(commands []string, all map[string][]string) { switch { case len(commandBinds) == 0: case len(commandBinds) > 5: - var firstBinds []string + firstBinds := make([]string, 0, 5) for i := 0; i < 5; i++ { firstBinds = append(firstBinds, "\""+commandBinds[i]+"\"") diff --git a/readline/internal/keymap/cursor.go b/readline/internal/keymap/cursor.go index 380057e..b01b5c7 100644 --- a/readline/internal/keymap/cursor.go +++ b/readline/internal/keymap/cursor.go @@ -59,14 +59,14 @@ func (m *Engine) PrintCursor(keymap Mode) { modeSet := strings.TrimSpace(m.config.GetString(cursorOptname)) if _, valid := cursors[CursorStyle(modeSet)]; valid { - term.Print(cursors[CursorStyle(modeSet)]) + term.WriteString(cursors[CursorStyle(modeSet)]) return } if defaultCur, valid := defaultCursors[keymap]; valid { - term.Print(cursors[defaultCur]) + term.WriteString(cursors[defaultCur]) return } - term.Print(cursors[cursor]) + term.WriteString(cursors[cursor]) } diff --git a/readline/internal/keymap/dispatch.go b/readline/internal/keymap/dispatch.go index d977a8c..946c4b2 100644 --- a/readline/internal/keymap/dispatch.go +++ b/readline/internal/keymap/dispatch.go @@ -187,7 +187,7 @@ func (m *Engine) matchBind(keys []byte, binds map[string]inputrc.Bind) (inputrc. var prefixed []inputrc.Bind // Make a sorted list with all keys in the binds map. - var sequences []string + sequences := make([]string, 0, len(binds)) for sequence := range binds { sequences = append(sequences, sequence) } @@ -275,6 +275,7 @@ func (m *Engine) handleEscape(main bool) (bind inputrc.Bind, cmd func(), pref bo func (m *Engine) makeMatch(active, prefixed inputrc.Bind) (prefix bool) { m.active = active m.prefixed = prefixed + return m.prefixed.Action != "" } diff --git a/readline/internal/keymap/dispatch_test.go b/readline/internal/keymap/dispatch_test.go new file mode 100644 index 0000000..6ccc326 --- /dev/null +++ b/readline/internal/keymap/dispatch_test.go @@ -0,0 +1,101 @@ +package keymap + +import ( + "testing" + + "github.com/chainreactors/tui/readline/inputrc" + "github.com/chainreactors/tui/readline/internal/core" + "github.com/chainreactors/tui/readline/internal/strutil" +) + +// builtinBindMaps returns every keymap exactly as the dispatcher sees it after +// a default engine is built (GNU defaults + this library's builtin overlays). +func builtinBindMaps(t *testing.T) map[string]map[string]inputrc.Bind { + t.Helper() + + _, cfg := NewEngine(&core.Keys{}, &core.Iterations{}) + + return cfg.Binds +} + +// collisions returns, for a keymap, the ConvertMeta key bytes that more than one +// sequence maps to with DIFFERING binds — inputs whose exact match would depend +// on iteration order if matchBind did not impose a deterministic order. +func collisions(binds map[string]inputrc.Bind) map[string][]inputrc.Bind { + bySeq := make(map[string][]inputrc.Bind) + + for sequence, bind := range binds { + seq := strutil.ConvertMeta([]rune(sequence)) + bySeq[seq] = append(bySeq[seq], bind) + } + + out := make(map[string][]inputrc.Bind) + + for seq, list := range bySeq { + differ := false + + for _, b := range list[1:] { + if b != list[0] { + differ = true + + break + } + } + + if differ { + out[seq] = list + } + } + + return out +} + +// TestMatchBindSortIsLoadBearing documents why matchBind must impose a +// deterministic order: the default keymaps DO contain sequences that collapse +// (via ConvertMeta) to the same key bytes with different actions, e.g. ESC-prefixed +// meta bindings overlapping a self-insert. With those collisions present, dropping +// the ordering (as a naive optimization would) makes the exact `match` depend on +// Go's randomized map iteration — i.e. nondeterministic keybind resolution. If +// this ever reports zero collisions, the determinism requirement can be revisited. +func TestMatchBindSortIsLoadBearing(t *testing.T) { + binds := builtinBindMaps(t)[string(Emacs)] + + cols := collisions(binds) + if len(cols) == 0 { + t.Fatal("expected ConvertMeta collisions in the emacs keymap; if truly none, matchBind no longer needs to order its matches") + } + + t.Logf("emacs keymap has %d colliding key sequences whose exact match is disambiguated only by matchBind's ordering", len(cols)) +} + +// TestMatchBindResolvesDeterministically guards the property the ordering buys: +// for a colliding input, matchBind must return the SAME exact match on every +// call despite map iteration randomness. Runs many times so a regression to +// unordered iteration would be caught. +func TestMatchBindResolvesDeterministically(t *testing.T) { + eng := &Engine{} + binds := builtinBindMaps(t)[string(Emacs)] + + cols := collisions(binds) + if len(cols) == 0 { + t.Skip("no collisions to probe") + } + + // Pick one colliding input deterministically (smallest key bytes). + var probe string + + for seq := range cols { + if probe == "" || seq < probe { + probe = seq + } + } + + want, _ := eng.matchBind([]byte(probe), binds) + + for range 64 { + got, _ := eng.matchBind([]byte(probe), binds) + if got != want { + t.Fatalf("matchBind(%q) is nondeterministic: got %+v, first saw %+v", probe, got, want) + } + } +} diff --git a/readline/internal/keymap/emacs.go b/readline/internal/keymap/emacs.go index 62851f9..db59d7a 100644 --- a/readline/internal/keymap/emacs.go +++ b/readline/internal/keymap/emacs.go @@ -6,8 +6,12 @@ var unescape = inputrc.Unescape // emacsKeys are the default keymaps in Emacs mode. var emacsKeys = map[string]inputrc.Bind{ - unescape(`\C-D`): {Action: "end-of-file"}, - unescape(`\C-h`): {Action: "backward-kill-word"}, + unescape(`\C-D`): {Action: "end-of-file"}, + // NOTE: \C-h is deliberately NOT overridden here. Many terminals send ^H + // for Backspace, and the GNU default (inputrc DefaultBinds) binds \C-h to + // backward-delete-char. Binding it to backward-kill-word made Backspace + // delete a whole word on those terminals; leaving it unset restores the + // standard char-delete behaviour. unescape(`\C-N`): {Action: "down-line-or-history"}, unescape(`\C-P`): {Action: "up-line-or-history"}, unescape(`\C-x\C-b`): {Action: "vi-match"}, diff --git a/readline/internal/keymap/emacs_test.go b/readline/internal/keymap/emacs_test.go new file mode 100644 index 0000000..f6d5f53 --- /dev/null +++ b/readline/internal/keymap/emacs_test.go @@ -0,0 +1,29 @@ +package keymap + +import ( + "testing" + + "github.com/chainreactors/tui/readline/inputrc" + "github.com/chainreactors/tui/readline/internal/core" +) + +// TestEmacsBackspaceDeletesChar guards the \C-h binding: on many terminals +// Backspace sends ^H, so \C-h must resolve to backward-delete-char (the GNU +// default), not backward-kill-word, which would delete a whole word on every +// Backspace. The emacsKeys overlay must therefore NOT override the default. +func TestEmacsBackspaceDeletesChar(t *testing.T) { + keys := &core.Keys{} + iters := &core.Iterations{} + + _, cfg := NewEngine(keys, iters) + + got := cfg.Binds[string(Emacs)][inputrc.Unescape(`\C-h`)].Action + + if got == "backward-kill-word" { + t.Fatal(`\C-h is bound to backward-kill-word: Backspace would delete a whole word on terminals that send ^H`) + } + + if got != "backward-delete-char" { + t.Fatalf(`\C-h resolved to %q, want "backward-delete-char"`, got) + } +} diff --git a/readline/internal/keymap/engine.go b/readline/internal/keymap/engine.go index 1764d70..a79f08e 100644 --- a/readline/internal/keymap/engine.go +++ b/readline/internal/keymap/engine.go @@ -36,7 +36,7 @@ func NewEngine(keys *core.Keys, i *core.Iterations, opts ...inputrc.Option) (*En } // Load the inputrc configurations and set up related things. - modes.ReloadConfig(opts...) + _ = modes.ReloadConfig(opts...) return modes, modes.config } @@ -130,7 +130,7 @@ func (m *Engine) IsEmacs() bool { // to the screen. If inputrcFormat is true, it displays it formatted such that // the output can be reused in an .inputrc file. func (m *Engine) PrintBinds(keymap string, inputrcFormat bool) { - var commands []string + commands := make([]string, 0, len(m.commands)) for command := range m.commands { commands = append(commands, command) @@ -180,7 +180,7 @@ func (m *Engine) InputIsTerminator() bool { binds[sequence] = inputrc.Bind{Action: "abort", Macro: false} } - bind, _, _, _ := m.dispatchKeys(binds) + bind, _, _, _ := m.dispatchKeys(binds) //nolint:dogsled // Only the resolved bind is needed here. return bind.Action == "abort" } diff --git a/readline/internal/keymap/pending.go b/readline/internal/keymap/pending.go index 888111e..eaba7ac 100644 --- a/readline/internal/keymap/pending.go +++ b/readline/internal/keymap/pending.go @@ -1,17 +1,5 @@ package keymap -import "github.com/chainreactors/tui/readline/inputrc" - -// action is represents the action of a widget, the number of times -// this widget needs to be run, and an optional operator argument. -// Most of the time we don't need this operator. -// -// Those actions are mostly used by widgets which make the shell enter -// the Vim operator pending mode, and thus require another key to be read. -type action struct { - command inputrc.Bind -} - // Pending registers a command as waiting for another command to run first, // such as yank/delete/change actions, which accept/require a movement command. func (m *Engine) Pending() { diff --git a/readline/internal/macro/engine.go b/readline/internal/macro/engine.go index 9f3f474..194d342 100644 --- a/readline/internal/macro/engine.go +++ b/readline/internal/macro/engine.go @@ -158,7 +158,7 @@ func (e *Engine) PrintLastMacro() { // PrintAllMacros dumps all macros to the screen, which one line // per saved macro sequence, next to its corresponding key ID. func (e *Engine) PrintAllMacros() { - var macroIDs []rune + macroIDs := make([]rune, 0, len(e.macros)) for key := range e.macros { macroIDs = append(macroIDs, key) diff --git a/readline/internal/strutil/key.go b/readline/internal/strutil/key.go index 5f1d4d9..f424baf 100644 --- a/readline/internal/strutil/key.go +++ b/readline/internal/strutil/key.go @@ -9,9 +9,9 @@ func ConvertMeta(keys []rune) string { return string(keys) } - converted := make([]rune, 0) + converted := make([]rune, 0, len(keys)) - for i := 0; i < len(keys); i++ { + for i := range keys { char := keys[i] if !inputrc.IsMeta(char) { diff --git a/readline/internal/strutil/keyword.go b/readline/internal/strutil/keyword.go index 72ee969..7063671 100644 --- a/readline/internal/strutil/keyword.go +++ b/readline/internal/strutil/keyword.go @@ -112,7 +112,7 @@ func switchHexa(word string, inc int) (done bool, switched string, bpos, epos in match := hexadecimal.FindString(word) if match == "" { - return + return done, switched, bpos, epos } done = true @@ -131,7 +131,7 @@ func switchHexa(word string, inc int) (done bool, switched string, bpos, epos in num, err := strconv.ParseInt(hexVal, 16, 64) if err != nil { done = false - return + return done, switched, bpos, epos } max64Bit := big.NewInt(maxInt) @@ -185,7 +185,7 @@ func switchBinary(word string, inc int) (done bool, switched string, bpos, epos match := binary.FindString(word) if match == "" { - return + return done, switched, bpos, epos } done = true @@ -200,7 +200,7 @@ func switchBinary(word string, inc int) (done bool, switched string, bpos, epos num, err := strconv.ParseInt(binVal, 2, 64) if err != nil { done = false - return + return done, switched, bpos, epos } max64Bit := big.NewInt(maxInt) @@ -305,7 +305,7 @@ func switchBoolean(word string, _ bool, _ int) (done bool, switched string, bpos switched, done = booleans[strings.ToLower(word)] if !done { - return + return done, switched, bpos, epos } done = true @@ -322,7 +322,7 @@ func switchBoolean(word string, _ bool, _ int) (done bool, switched string, bpos return done, switched, bpos, epos } -func switchWeekday(word string, inc bool, _ int) (done bool, switched string, bpos, epos int) { +func switchWeekday(_ string, _ bool, _ int) (done bool, switched string, bpos, epos int) { return } @@ -348,7 +348,7 @@ func switchOperator(word string, _ bool, _ int) (done bool, switched string, bpo switched, done = operators[strings.ToLower(word)] if !done { - return + return done, switched, bpos, epos } done = true diff --git a/readline/internal/strutil/split.go b/readline/internal/strutil/split.go index 1541a28..e154abf 100644 --- a/readline/internal/strutil/split.go +++ b/readline/internal/strutil/split.go @@ -49,8 +49,9 @@ func Split(input string) (words []string, err error) { next := input[l:] if len(next) == 0 { err = errUnterminatedEscape - return + return words, err } + c2, l2 := utf8.DecodeRuneInString(next) if c2 == '\n' { input = next[l2:] @@ -59,13 +60,16 @@ func Split(input string) (words []string, err error) { } var word string + word, input, err = splitWord(input, &buf) if err != nil { - return + return words, err } + words = append(words, word) } - return + + return words, err } func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { @@ -77,19 +81,20 @@ raw: for len(cur) > 0 { c, l := utf8.DecodeRuneInString(cur) cur = cur[l:] - if c == singleChar { + switch { + case c == singleChar: buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto single - } else if c == doubleChar { + case c == doubleChar: buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto double - } else if c == escapeChar { + case c == escapeChar: buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto escape - } else if strings.ContainsRune(splitChars, c) { + case strings.ContainsRune(splitChars, c): buf.WriteString(input[0 : len(input)-len(cur)-l]) return buf.String(), cur, nil } @@ -107,13 +112,13 @@ escape: return "", "", errUnterminatedEscape } c, l := utf8.DecodeRuneInString(input) - if c == '\n' { - // a backslash-escaped newline is elided from the output entirely - } else { + // A backslash-escaped newline is elided from the output entirely. + if c != '\n' { buf.WriteString(input[:l]) } input = input[l:] } + goto raw single: @@ -133,19 +138,19 @@ double: for len(cur) > 0 { c, l := utf8.DecodeRuneInString(cur) cur = cur[l:] - if c == doubleChar { + switch c { + case doubleChar: buf.WriteString(input[0 : len(input)-len(cur)-l]) input = cur goto raw - } else if c == escapeChar { + case 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 { + // A newline is special: skip the backslash entirely (write nothing). + if c2 != '\n' { buf.WriteRune(c2) } input = cur diff --git a/readline/internal/strutil/surround_test.go b/readline/internal/strutil/surround_test.go new file mode 100644 index 0000000..7d6aa64 --- /dev/null +++ b/readline/internal/strutil/surround_test.go @@ -0,0 +1,95 @@ +package strutil + +import ( + "testing" +) + +func TestGetQuotedWordStart(t *testing.T) { + tests := []struct { + name string + line string + wantUnclosed bool + wantPos int + }{ + { + name: "Empty", + line: "", + wantUnclosed: false, + wantPos: -1, + }, + { + name: "Single word", + line: "word", + wantUnclosed: false, + wantPos: -1, + }, + { + name: "Unclosed double", + line: "\"word", + wantUnclosed: true, + wantPos: 0, + }, + { + name: "Closed double", + line: "\"word\"", + wantUnclosed: false, + wantPos: -1, // Or whatever dpos is left at? dpos tracks OPENING. + // If closed, inDouble is false. Returns false, -1. + }, + { + name: "Unclosed single", + line: "'word", + wantUnclosed: true, + wantPos: 0, + }, + { + name: "Escaped quote in double", + line: "\"word \\\"", + wantUnclosed: true, + wantPos: 0, + }, + { + name: "Escaped quote in single (literal)", + line: "'word \\'", + wantUnclosed: false, + wantPos: -1, + }, + { + name: "Nested quotes (single in double)", + line: "\"'\"", + wantUnclosed: false, + wantPos: -1, + }, + { + name: "Nested quotes (double in single)", + line: "'\"'", + wantUnclosed: false, + wantPos: -1, + }, + { + name: "Balanced nested", + line: "\"'hello'\"", + wantUnclosed: false, + wantPos: -1, + }, + { + name: "Multiple words unclosed", + line: "hello \"world", + wantUnclosed: true, + wantPos: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + unclosed, pos := GetQuotedWordStart([]rune(tt.line)) + if unclosed != tt.wantUnclosed { + t.Errorf("GetQuotedWordStart() unclosed = %v, want %v", unclosed, tt.wantUnclosed) + } + + if unclosed && pos != tt.wantPos { + t.Errorf("GetQuotedWordStart() pos = %v, want %v", pos, tt.wantPos) + } + }) + } +} diff --git a/readline/internal/term/codes.go b/readline/internal/term/codes.go index 359ea06..8e3b54e 100644 --- a/readline/internal/term/codes.go +++ b/readline/internal/term/codes.go @@ -16,6 +16,9 @@ const ( RestoreCursorPos = "\x1b8" HideCursor = "\x1b[?25l" ShowCursor = "\x1b[?25h" + + BracketedPasteStart = "\x1b[?2004h" + BracketedPasteEnd = "\x1b[?2004l" ) // Some core keys needed by some stuff. diff --git a/readline/internal/term/output.go b/readline/internal/term/output.go new file mode 100644 index 0000000..b250876 --- /dev/null +++ b/readline/internal/term/output.go @@ -0,0 +1,106 @@ +package term + +import ( + "bufio" + "fmt" + "io" + "sync" +) + +// renderOut, when non-nil, overrides the destination for rendered terminal +// output. It is a test seam; in production it stays nil and output goes to the +// active terminal output. Output is distinct from termFile (stderr), used only +// for terminal *queries* (size, cursor position). +var renderOut io.Writer + +func out() io.Writer { + if renderOut != nil { + return renderOut + } + + return Output() +} + +// Output buffering lets a whole frame (one Refresh) be coalesced into a single +// write to the terminal instead of dozens of small writes. This removes the +// inter-write flicker a terminal can show mid-frame and cuts write syscalls per +// keystroke from many to one. +// +// All rendering happens on the readline goroutine, so this state is effectively +// single-threaded; the mutex is a cheap guard against accidental concurrent use +// (e.g. a stray write from another goroutine) rather than a hot contention path. +var ( + outMu sync.Mutex + outDepth int + outBuf *bufio.Writer +) + +// BeginBuffer starts buffering terminal writes. Calls nest: only the outermost +// BeginBuffer/EndBuffer pair allocates and flushes the buffer, so render +// entrypoints can be wrapped independently without coordinating. +func BeginBuffer() { + outMu.Lock() + defer outMu.Unlock() + + outDepth++ + if outDepth == 1 { + outBuf = bufio.NewWriterSize(out(), 64*1024) + } +} + +// EndBuffer flushes and tears down the buffer when leaving the outermost frame. +// It is safe to call unmatched (it no-ops at depth 0), so it can be deferred +// even if BeginBuffer is on an early-return path. +func EndBuffer() { + outMu.Lock() + defer outMu.Unlock() + + if outDepth == 0 { + return + } + + outDepth-- + + if outDepth == 0 && outBuf != nil { + _ = outBuf.Flush() + outBuf = nil + } +} + +// FlushBuffer pushes everything buffered so far to the terminal without ending +// the frame. It must be called before any operation that READS the terminal and +// depends on prior output being on screen — chiefly an ESC[6n cursor-position +// query (see GetCursorPos): otherwise the prompt would still sit in the buffer +// and the reported cursor position would be wrong. +func FlushBuffer() { + outMu.Lock() + defer outMu.Unlock() + + if outBuf != nil { + _ = outBuf.Flush() + } +} + +// WriteString writes s to the terminal: into the frame buffer when one is +// active, otherwise straight to the output. Empty strings are dropped so callers +// need not guard. +func WriteString(s string) { + if s == "" { + return + } + + outMu.Lock() + defer outMu.Unlock() + + if outDepth > 0 && outBuf != nil { + _, _ = io.WriteString(outBuf, s) + return + } + + _, _ = io.WriteString(out(), s) +} + +// Printf formats and writes to the terminal, honouring the frame buffer. +func Printf(format string, a ...interface{}) { + WriteString(fmt.Sprintf(format, a...)) +} diff --git a/readline/internal/term/output_test.go b/readline/internal/term/output_test.go new file mode 100644 index 0000000..8f53ab5 --- /dev/null +++ b/readline/internal/term/output_test.go @@ -0,0 +1,126 @@ +package term + +import ( + "bytes" + "testing" +) + +// countWriter records how many times Write was called (i.e. how many syscalls a +// real terminal would see) and accumulates the bytes for content assertions. +type countWriter struct { + writes int + buf bytes.Buffer +} + +func (c *countWriter) Write(p []byte) (int, error) { + c.writes++ + + return c.buf.Write(p) +} + +// TestWriteStringDirect: with no active buffer, each non-empty WriteString is a +// direct write, and empty strings are dropped. +func TestWriteStringDirect(t *testing.T) { + cw := &countWriter{} + renderOut = cw + + defer func() { renderOut = nil }() + + WriteString("hello") + WriteString("") // dropped, no write + WriteString(" world") + + if got := cw.buf.String(); got != "hello world" { + t.Fatalf("content = %q, want %q", got, "hello world") + } + + if cw.writes != 2 { + t.Fatalf("direct writes = %d, want 2", cw.writes) + } +} + +// TestBufferCoalescesToSingleWrite: a whole frame's worth of writes is held +// until EndBuffer, then flushed as one write — the core flicker/syscall win. +func TestBufferCoalescesToSingleWrite(t *testing.T) { + cw := &countWriter{} + renderOut = cw + + defer func() { renderOut = nil }() + + BeginBuffer() + WriteString("\x1b[?25l") + WriteString("prompt> ") + WriteString("line") + + if cw.writes != 0 { + t.Fatalf("expected nothing on screen before EndBuffer, got %d writes", cw.writes) + } + + EndBuffer() + + if cw.writes != 1 { + t.Fatalf("expected a single coalesced write, got %d", cw.writes) + } + + if got := cw.buf.String(); got != "\x1b[?25lprompt> line" { + t.Fatalf("content = %q", got) + } +} + +// TestBufferNestingFlushesOnce: nested Begin/End pairs only flush at the +// outermost End, so render entrypoints can wrap independently. +func TestBufferNestingFlushesOnce(t *testing.T) { + cw := &countWriter{} + renderOut = cw + + defer func() { renderOut = nil }() + + BeginBuffer() + WriteString("a") + BeginBuffer() // nested + WriteString("b") + EndBuffer() // inner: must NOT flush + + if cw.writes != 0 { + t.Fatalf("inner EndBuffer flushed early: %d writes", cw.writes) + } + + WriteString("c") + EndBuffer() // outer: flush + + if cw.writes != 1 { + t.Fatalf("expected one flush, got %d", cw.writes) + } + + if got := cw.buf.String(); got != "abc" { + t.Fatalf("content = %q, want abc", got) + } +} + +// TestFlushBufferMidFrame: FlushBuffer pushes what's buffered so far (e.g. +// before an ESC[6n query) without ending the frame. +func TestFlushBufferMidFrame(t *testing.T) { + cw := &countWriter{} + renderOut = cw + + defer func() { renderOut = nil }() + + BeginBuffer() + WriteString("prompt") + FlushBuffer() // the prompt must now be on screen before a cursor query + + if cw.writes != 1 || cw.buf.String() != "prompt" { + t.Fatalf("FlushBuffer did not flush partial frame: writes=%d content=%q", cw.writes, cw.buf.String()) + } + + WriteString("line") + EndBuffer() + + if cw.writes != 2 { + t.Fatalf("expected 2 writes (mid-flush + end), got %d", cw.writes) + } + + if got := cw.buf.String(); got != "promptline" { + t.Fatalf("content = %q", got) + } +} diff --git a/readline/internal/term/term.go b/readline/internal/term/term.go index d232fd1..e801bd1 100644 --- a/readline/internal/term/term.go +++ b/readline/internal/term/term.go @@ -10,51 +10,49 @@ import ( "sync" ) -// Those variables are very important to realine low-level code: all virtual terminal -// escape sequences should always be sent and read through the raw terminal file, even -// if people start using io.MultiWriters and os.Pipes involving basic IO. -var ( - stdoutTerm *os.File - stdinTerm *os.File - stderrTerm *os.File - - outputs sync.Map - controls sync.Map -) - -func init() { - stdoutTerm = os.Stdout - stdinTerm = os.Stdin - stderrTerm = os.Stderr -} +// termFile is the file descriptor used for all low-level terminal queries (size, +// cursor position) and escape sequences. We deliberately use stderr rather than +// stdout: stdout is the stream most likely to be redirected (e.g. `app | other`), +// whereas stderr usually stays attached to the controlling terminal, giving a +// reliable terminal size even when the program's output is piped. +var termFile = os.Stderr // fallback terminal width when we can't get it through query. var defaultTermWidth = 80 // Control is the terminal capability subset readline internals need. type Control interface { + IsTerminal() bool Size() (cols, rows int) OnResize(func(cols, rows int)) func() } +var ( + outputs sync.Map + controls sync.Map +) + // Activate binds output/control to the current goroutine until the returned // restore function is called. func Activate(output io.Writer, control Control) func() { id := goid() oldOutput, hadOutput := outputs.Load(id) oldControl, hadControl := controls.Load(id) + if output != nil { outputs.Store(id, output) } if control != nil { controls.Store(id, control) } + return func() { if hadOutput { outputs.Store(id, oldOutput) } else { outputs.Delete(id) } + if hadControl { controls.Store(id, oldControl) } else { @@ -70,7 +68,8 @@ func Output() io.Writer { return w } } - return stdoutTerm + + return os.Stdout } // CurrentControl returns the control bound to the current readline session. @@ -80,6 +79,7 @@ func CurrentControl() Control { return c } } + return nil } @@ -88,28 +88,22 @@ func Print(a ...interface{}) (int, error) { return fmt.Fprint(Output(), a...) } -// Printf writes to the active terminal output. -func Printf(format string, a ...interface{}) (int, error) { - return fmt.Fprintf(Output(), format, a...) -} - // Println writes to the active terminal output. func Println(a ...interface{}) (int, error) { return fmt.Fprintln(Output(), a...) } -// GetWidth returns the width of Stdout or 80 if the width cannot be established. +// GetWidth returns the width of the terminal or 80 if it cannot be established. func GetWidth() (termWidth int) { - if control, ok := controls.Load(goid()); ok { - if c, ok := control.(Control); ok && c != nil { - width, _ := c.Size() - if width > 0 { - return width - } + if control := CurrentControl(); control != nil { + width, _ := control.Size() + if width > 0 { + return width } } + var err error - fd := int(stdoutTerm.Fd()) + fd := int(termFile.Fd()) termWidth, _, err = GetSize(fd) if err != nil || termWidth == 0 { @@ -122,15 +116,14 @@ func GetWidth() (termWidth int) { // GetLength returns the length of the terminal // (Y length), or 80 if it cannot be established. func GetLength() int { - if control, ok := controls.Load(goid()); ok { - if c, ok := control.(Control); ok && c != nil { - _, length := c.Size() - if length > 0 { - return length - } + if control := CurrentControl(); control != nil { + _, length := control.Size() + if length > 0 { + return length } } - termFd := int(stdoutTerm.Fd()) + + termFd := int(termFile.Fd()) _, length, err := GetSize(termFd) if err != nil || length == 0 { @@ -140,19 +133,27 @@ func GetLength() int { return length } +func printf(format string, a ...interface{}) { + WriteString(fmt.Sprintf(format, a...)) +} + // OnResize registers a resize callback on the active terminal control. func OnResize(fn func(cols, rows int)) func() { - if control, ok := controls.Load(goid()); ok { - if c, ok := control.(Control); ok && c != nil { - return c.OnResize(fn) - } + if control := CurrentControl(); control != nil { + return control.OnResize(fn) } + return func() {} } -func printf(format string, a ...interface{}) { - s := fmt.Sprintf(format, a...) - Print(s) +// EnableBracketedPaste enables bracketed paste mode. +func EnableBracketedPaste() { + WriteString(BracketedPasteStart) +} + +// DisableBracketedPaste disables bracketed paste mode. +func DisableBracketedPaste() { + WriteString(BracketedPasteEnd) } func goid() uint64 { @@ -162,9 +163,11 @@ func goid() uint64 { if len(fields) < 2 { return 0 } + id, err := strconv.ParseUint(fields[1], 10, 64) if err != nil { return 0 } + return id } diff --git a/readline/internal/ui/hint.go b/readline/internal/ui/hint.go index b9c317b..5f69870 100644 --- a/readline/internal/ui/hint.go +++ b/readline/internal/ui/hint.go @@ -2,8 +2,10 @@ package ui import ( "strings" + "sync" "github.com/chainreactors/tui/readline/internal/color" + "github.com/chainreactors/tui/readline/internal/core" "github.com/chainreactors/tui/readline/internal/strutil" "github.com/chainreactors/tui/readline/internal/term" ) @@ -11,18 +13,65 @@ import ( // Hint is in charge of printing the usage messages below the input line. // Various other UI components have access to it so that they can feed // specialized usage messages to it, like completions. +// +// The hint area is composed of several independent lanes, rendered top to +// bottom in the following order: +// +// - persistent: status set by internal commands (Vim registers, iterations). +// - provided: passive hint computed from the current input line by a +// user-registered hint provider (see Shell.SetHintProvider). +// - transient: one-shot status messages, typically pushed from an +// asynchronous producer (see Shell.SetTransientHint). These survive +// keystrokes that drive the completion/isearch lane. +// - text: hints owned by the completion and incremental-search engines, +// plus temporary command messages. +// +// Keeping these in distinct lanes lets passive hinting, async status reporting +// and completion hints coexist without clobbering one another. +// +// All fields are guarded by mu: the transient lane in particular may be written +// from another goroutine while the main loop renders, so every accessor takes +// the lock. type Hint struct { + mu sync.RWMutex text []rune persistent []rune + provided []rune + transient []rune cleanup bool temp bool set bool + + // provider, when set, computes the passive (provided) hint from the current + // input line and cursor. It is re-evaluated on every refresh by the display + // engine through UpdateProvided. + provider func(line []rune, cursor int) []rune + + // refresh, when set, wakes the render loop after an async lane change (e.g. + // SetTransient from another goroutine). Wired by the shell to the input wake + // primitive; integrators do not call it directly. + refresh func() +} + +// NewHint creates a hint area wired to wake the render loop on asynchronous lane +// changes (such as SetTransient called from another goroutine), through the +// keys' input wake primitive. +func NewHint(keys *core.Keys) *Hint { + hint := &Hint{} + if keys != nil { + hint.refresh = keys.RequestRefresh + } + + return hint } // Set sets the hint message to the given text. // Generally, this hint message will persist until either a command // or the completion system overwrites it, or if hint.Reset() is called. func (h *Hint) Set(hint string) { + h.mu.Lock() + defer h.mu.Unlock() + h.text = []rune(hint) h.set = true } @@ -30,6 +79,9 @@ func (h *Hint) Set(hint string) { // SetTemporary sets a hint message that will be cleared at the next keypress // or command being run, which generally coincides with the next redisplay. func (h *Hint) SetTemporary(hint string) { + h.mu.Lock() + defer h.mu.Unlock() + h.text = []rune(hint) h.set = true h.temp = true @@ -38,11 +90,102 @@ func (h *Hint) SetTemporary(hint string) { // Persist adds a hint message to be persistently // displayed until hint.ResetPersist() is called. func (h *Hint) Persist(hint string) { + h.mu.Lock() + defer h.mu.Unlock() + h.persistent = []rune(hint) } +// SetProvided sets the passive (provider) hint lane directly. It is normally +// recomputed from the current input line on every refresh (see SetProvider); +// an empty string clears the lane. This lane renders below the persistent lane +// and above the transient and completion lanes. +func (h *Hint) SetProvided(hint string) { + h.mu.Lock() + defer h.mu.Unlock() + + h.provided = []rune(hint) +} + +// SetProvider registers a function that computes the passive (provided) hint +// from the current input line and cursor position. It returns the hint text to +// show (return nil/empty for no hint). The provider is re-evaluated on every +// refresh, so the hint tracks the input as it changes. +// +// This is the "passive/background hinting" lane: it renders above the transient +// (async status) lane and the completion hints. Pass nil to remove the provider. +func (h *Hint) SetProvider(provider func(line []rune, cursor int) []rune) { + h.mu.Lock() + defer h.mu.Unlock() + + h.provider = provider + + if provider == nil { + h.provided = make([]rune, 0) + } +} + +// UpdateProvided re-evaluates the registered provider (if any) against the given +// line and cursor and stores the result in the passive (provided) lane. It is +// called by the display engine on each refresh. The provider runs outside the +// lock so it may safely query shell state. +func (h *Hint) UpdateProvided(line []rune, cursor int) { + h.mu.RLock() + provider := h.provider + h.mu.RUnlock() + + if provider == nil { + return + } + + h.SetProvided(string(provider(line, cursor))) +} + +// SetTransient sets the transient (async status) hint lane. It is safe to call +// from any goroutine, and wakes an idle render loop so the message appears +// immediately. The message persists until ClearTransient is called or it is +// replaced; crucially it is NOT cleared by the completion or incremental search +// engines. It renders above the completion lane and below the provider lane. +func (h *Hint) SetTransient(hint string) { + h.mu.Lock() + h.transient = []rune(hint) + refresh := h.refresh + h.mu.Unlock() + + if refresh != nil { + refresh() + } +} + +// ClearTransient drops the transient (async status) hint lane. It is safe to +// call from any goroutine, and wakes an idle render loop so the change is shown. +func (h *Hint) ClearTransient() { + h.mu.Lock() + h.cleanup = h.cleanup || len(h.transient) > 0 + h.transient = make([]rune, 0) + refresh := h.refresh + h.mu.Unlock() + + if refresh != nil { + refresh() + } +} + +// SetRefreshFunc registers a callback used to wake the render loop when an +// async hint lane changes (e.g. SetTransient from another goroutine). The shell +// wires this to the input wake primitive; integrators do not call it directly. +func (h *Hint) SetRefreshFunc(refresh func()) { + h.mu.Lock() + defer h.mu.Unlock() + + h.refresh = refresh +} + // Text returns the current hint text. func (h *Hint) Text() string { + h.mu.RLock() + defer h.mu.RUnlock() + return string(h.text) } @@ -51,11 +194,19 @@ func (h *Hint) Text() string { // is an active hint, in which case they might want to append to // it instead of overwriting it altogether (like in isearch mode). func (h *Hint) Len() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.text) } -// Reset removes the hint message. +// Reset removes the hint message. It only clears the completion/command lane +// (text); the persistent, provider and transient lanes are left untouched, so +// async status and passive hints survive completion/isearch activity. func (h *Hint) Reset() { + h.mu.Lock() + defer h.mu.Unlock() + h.text = make([]rune, 0) h.temp = false h.set = false @@ -63,21 +214,32 @@ func (h *Hint) Reset() { // ResetPersist drops the persistent hint section. func (h *Hint) ResetPersist() { + h.mu.Lock() + defer h.mu.Unlock() + h.cleanup = len(h.persistent) > 0 h.persistent = make([]rune, 0) } -// DisplayHint prints the hint (persistent and/or temporary) sections. +// DisplayHint prints the hint (persistent, provided, transient and/or +// completion) sections. func DisplayHint(hint *Hint) { + hint.mu.Lock() + defer hint.mu.Unlock() + + // The completion/command (text) lane supports one-shot temporary messages: + // keep them for this render, clear them on the next. if hint.temp && hint.set { hint.set = false } else if hint.temp { - hint.Reset() + hint.text = make([]rune, 0) + hint.temp = false + hint.set = false } - if len(hint.text) == 0 && len(hint.persistent) == 0 { + if hint.empty() { if hint.cleanup { - term.Print(term.ClearLineAfter) + term.WriteString(term.ClearLineAfter) } hint.cleanup = false @@ -85,7 +247,7 @@ func DisplayHint(hint *Hint) { return } - text := hint.renderHint() + text := hint.renderLocked() if strutil.RealLength(text) == 0 { return @@ -94,17 +256,27 @@ func DisplayHint(hint *Hint) { text += term.ClearLineAfter + color.Reset if len(text) > 0 { - term.Print(text) + term.WriteString(text) } } -func (h *Hint) renderHint() (text string) { - if len(h.persistent) > 0 { - text += string(h.persistent) + term.NewlineReturn - } +// empty reports whether every hint lane is empty. The caller must hold the lock. +func (h *Hint) empty() bool { + return len(h.text) == 0 && + len(h.persistent) == 0 && + len(h.provided) == 0 && + len(h.transient) == 0 +} - if len(h.text) > 0 { - text += string(h.text) + term.NewlineReturn +// renderLocked builds the hint block from all lanes. The caller must hold the +// lock (read or write). +func (h *Hint) renderLocked() (text string) { + // Top to bottom: status, passive provider hint, async transient status, + // then completion/command hints. + for _, lane := range [][]rune{h.persistent, h.provided, h.transient, h.text} { + if len(lane) > 0 { + text += string(lane) + term.NewlineReturn + } } if strutil.RealLength(text) == 0 { @@ -118,27 +290,39 @@ func (h *Hint) renderHint() (text string) { } // CoordinatesHint returns the number of terminal rows used by the hint. +// +// Each non-empty lane occupies its own row (wrapping when wider than the +// terminal), matching exactly what DisplayHint prints (one NewlineReturn per +// lane). It is counted from the lanes directly rather than from the rendered +// string, so the inter-lane separators do not get miscounted as extra rows. func CoordinatesHint(hint *Hint) int { - text := hint.renderHint() - - // Nothing to do if no real text - text = strings.TrimSuffix(text, term.ClearLineAfter+term.NewlineReturn) + hint.mu.RLock() + defer hint.mu.RUnlock() - if strutil.RealLength(text) == 0 { - return 0 + width := term.GetWidth() + if width <= 0 { + width = 80 } - // Otherwise compute the real length/span. usedY := 0 - lines := strings.Split(text, term.ClearLineAfter) - for i, line := range lines { - x, y := strutil.LineSpan([]rune(line), i, 0) - if x != 0 { - y++ + for _, lane := range [][]rune{hint.persistent, hint.provided, hint.transient, hint.text} { + if len(lane) == 0 { + continue } - usedY += y + // A lane may itself contain embedded newlines; count each sub-line, + // wrapping when it is wider than the terminal. + for _, sub := range strings.Split(string(lane), "\n") { + length := strutil.RealLength(sub) + + rows := length / width + if length%width != 0 || rows == 0 { + rows++ + } + + usedY += rows + } } return usedY diff --git a/readline/internal/ui/hint_test.go b/readline/internal/ui/hint_test.go new file mode 100644 index 0000000..b6d3741 --- /dev/null +++ b/readline/internal/ui/hint_test.go @@ -0,0 +1,47 @@ +package ui + +import "testing" + +// TestCoordinatesHintRowCount guards against a row-count regression in the hint +// area: CoordinatesHint must return exactly one row per non-empty lane (the same +// number of rows DisplayHint actually prints). A previous implementation split +// the rendered string on the per-line clear-sequence and double-counted every +// lane after the first (returning 2N-1 rows for N lanes), which made the helper +// repaint move the cursor up too far and drift the whole prompt upward by one +// row on every refresh that showed two or more hint lanes. +func TestCoordinatesHintRowCount(t *testing.T) { + cases := []struct { + name string + persistent, provided, transient, text string + want int + }{ + {"none", "", "", "", "", 0}, + {"one-text", "", "", "", "hello", 1}, + {"one-provided", "", "passive hint", "", "", 1}, + {"provided-and-transient", "", "passive hint", "async status", "", 2}, + {"persistent-and-text", "register", "", "", "completion", 2}, + {"three-lanes", "register", "passive", "async", "", 3}, + {"all-four-lanes", "register", "passive", "async", "completion", 4}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := &Hint{} + h.persistent = []rune(c.persistent) + h.provided = []rune(c.provided) + h.transient = []rune(c.transient) + h.text = []rune(c.text) + + // Normalize empty strings to empty slices so len()==0 lanes are skipped. + for _, p := range []*[]rune{&h.persistent, &h.provided, &h.transient, &h.text} { + if len(*p) == 0 { + *p = nil + } + } + + if got := CoordinatesHint(h); got != c.want { + t.Fatalf("CoordinatesHint = %d, want %d", got, c.want) + } + }) + } +} diff --git a/readline/internal/ui/prompt.go b/readline/internal/ui/prompt.go index 7050078..80e0270 100644 --- a/readline/internal/ui/prompt.go +++ b/readline/internal/ui/prompt.go @@ -6,15 +6,18 @@ import ( "strings" "github.com/chainreactors/tui/readline/inputrc" + "github.com/chainreactors/tui/readline/internal/color" "github.com/chainreactors/tui/readline/internal/core" "github.com/chainreactors/tui/readline/internal/keymap" "github.com/chainreactors/tui/readline/internal/strutil" "github.com/chainreactors/tui/readline/internal/term" ) -const ( - secondaryPromptDefault = "\x1b[1;30m\U00002514 \x1b[0m" - multilineColumnDefault = "\x1b[1;30m\U00002502 \x1b[0m" +var ( + // DefaultSecondaryPrompt is the default prompt to use for secondary lines. + DefaultSecondaryPrompt = color.FgBlackBright + "\U00002514 " + color.Reset + // DefaultMultilineColumn is the default prompt to use for multiline columns. + DefaultMultilineColumn = color.FgBlackBright + "\U00002502 " + color.Reset ) // Prompt stores all prompt rendering/generation functions and is @@ -108,10 +111,10 @@ func (p *Prompt) PrimaryPrint() { // Print the various lines. if prompt != "" { - term.Print(prompt) + term.WriteString(prompt) } - term.Print(lastPrompt) + term.WriteString(lastPrompt) // And compute coordinates p.primaryRows = strings.Count(prompt, "\n") @@ -125,6 +128,32 @@ func (p *Prompt) PrimaryUsed() int { return p.primaryRows } +// UpperPrint reprints every line of the primary prompt except the last one. +// It is used to keep a multi-line prompt's upper lines correct after the view +// has scrolled (e.g. when the prompt is rendered at the bottom of the window), +// since those lines are not otherwise repainted on a refresh. +// +// The cursor must be positioned at the first prompt row, column 0. On return +// the cursor is at column 0 of the last prompt line's row (the upper lines each +// end with a newline). +func (p *Prompt) UpperPrint() { + if p.primaryF == nil || p.primaryRows == 0 { + return + } + + upper, _ := p.formatPrimaryLines(p.primaryF()) + if upper == "" { + return + } + + // Clear each upper line as we reprint it, so a shorter prompt evaluation + // does not leave stale characters behind. + lines := strings.Split(strings.TrimSuffix(upper, "\n"), "\n") + for _, line := range lines { + term.WriteString(line + term.ClearLineAfter + term.NewlineReturn) + } +} + // LastPrint prints the last line of the primary prompt, if the latter // spans on several lines. If not, this function will actually print // the entire primary prompt, and PrimaryPrint() will not print anything. @@ -145,7 +174,7 @@ func (p *Prompt) LastPrint() { prompt := p.formatLastPrompt(lines[len(lines)-1]) - term.Print(prompt) + term.WriteString(prompt) p.primaryCols = strutil.RealLength(prompt) } @@ -177,11 +206,11 @@ func (p *Prompt) LastUsed() int { // which is always activated when the current input line is a multiline one. func (p *Prompt) SecondaryPrint() { if p.secondaryF != nil { - term.Print(p.secondaryF()) + term.WriteString(p.secondaryF()) return } - term.Print(secondaryPromptDefault) + term.WriteString(DefaultSecondaryPrompt) } // MultilineColumnPrint prints the multiline editor column status indicator. @@ -193,28 +222,26 @@ func (p *Prompt) MultilineColumnPrint() { switch { case numbered: - column := "" - for pos := 0; pos < p.line.Lines(); pos++ { - column += fmt.Sprintf("\n\x1b[1;30m%d\x1b[0m", pos+2) + var column strings.Builder + for pos := range p.line.Lines() { + fmt.Fprintf(&column, "\n"+color.FgBlackBright+"%d"+color.Reset+" ", pos+2) } - term.Print(column) - + term.WriteString(column.String()) case len(custom) > 0: - column := "" - for pos := 0; pos < p.line.Lines(); pos++ { - column += fmt.Sprintf("\n%s\x1b[0m", custom) + var column strings.Builder + for range p.line.Lines() { + fmt.Fprintf(&column, "\n%s\x1b[0m", custom) } - term.Print(column) - + term.WriteString(column.String()) case defaultCol: - column := "" - for pos := 0; pos < p.line.Lines(); pos++ { - column += "\n" + multilineColumnDefault + var column strings.Builder + for range p.line.Lines() { + column.WriteString("\n" + DefaultMultilineColumn) } - term.Print(column) + term.WriteString(column.String()) } } @@ -238,9 +265,9 @@ func (p *Prompt) RightPrint(startColumn int, force bool) { } if prompt, canPrint := p.formatRightPrompt(rprompt, startColumn); canPrint { - term.Print(prompt) + term.WriteString(prompt) } else { - term.Print(term.ClearLineAfter) + term.WriteString(term.ClearLineAfter) } } @@ -253,10 +280,10 @@ func (p *Prompt) TransientPrint() { // Clean everything below where the prompt will be printed. term.MoveCursorBackwards(term.GetWidth()) term.MoveCursorUp(p.primaryRows) - term.Print(term.ClearScreenBelow) + term.WriteString(term.ClearScreenBelow) // And print the prompt - term.Print(p.transientF()) + term.WriteString(p.transientF()) } // Refreshing returns true if the prompt is currently redisplaying diff --git a/readline/paste.go b/readline/paste.go index d0828b7..042f950 100644 --- a/readline/paste.go +++ b/readline/paste.go @@ -33,9 +33,13 @@ func (rl *Shell) InsertPastedText(text string) { if text == "" { return } - rl.History.Save() + if rl.History != nil { + rl.History.Save() + } rl.cursor.InsertAt([]rune(text)...) - rl.Display.Refresh() + if rl.Display != nil { + rl.Display.Refresh() + } } // HandleBracketedPastePending handles bytes that remain after an Escape binding diff --git a/readline/readline.go b/readline/readline.go index cbd7d28..c499371 100644 --- a/readline/readline.go +++ b/readline/readline.go @@ -65,18 +65,15 @@ func (rl *Shell) Readline() (string, error) { } } - // Prompts and cursor styles - rl.Display.PrintPrimaryPrompt() - - // Enable bracketed paste mode so terminals wrap pasted content - // in \e[200~ ... \e[201~ markers instead of injecting raw chars. - // Placed after prompt printing to avoid VT sequence interference. if rl.Config.GetBool("enable-bracketed-paste") { - term.Print("\033[?2004h") - defer term.Print("\033[?2004l") + term.EnableBracketedPaste() + defer term.DisableBracketedPaste() } + + // Prompts and cursor styles + rl.Display.PrintPrimaryPrompt() defer rl.Display.RefreshTransient() - defer term.Print(keymap.CursorStyle("default")) + defer term.WriteString(keymap.CursorStyle("default").String()) rl.init() @@ -84,6 +81,12 @@ func (rl *Shell) Readline() (string, error) { resize := display.WatchResize(rl.Display) defer close(resize) + // Async UI refresh: let background goroutines wake an idle loop (e.g. to + // show a transient hint pushed from another goroutine) via the input wake + // primitive. Torn down when the call returns. + rl.Keys.InitWake() + defer rl.Keys.CloseWake() + for { // Whether or not the command is resolved, let the macro // engine record the keys if currently recording a macro. @@ -95,6 +98,11 @@ func (rl *Shell) Readline() (string, error) { // been consumed but did not match any command. core.FlushUsed(rl.Keys) + // Apply any async-requested completion regeneration (RefreshCompletions) + // here on the main loop, before refreshing, so an active menu rebuilds + // in place while rendering stays single-writer. + rl.completer.ApplyRegen() + // Since we always update helpers after being asked to read // for user input again, we do it before actually reading it. rl.Display.Refresh() @@ -104,12 +112,25 @@ func (rl *Shell) Readline() (string, error) { // the macro engine has fed some keys in bulk when running one. core.WaitAvailableKeys(rl.Keys, rl.Config) + // A non-EOF read failure (e.g. the tty was revoked) is unrecoverable: + // return it so the caller can exit cleanly instead of the loop spinning + // on the dead input stream. + if err := rl.Keys.ReadError(); err != nil { + return "", err + } + // If the input is closed, we must return the line // and the error so that the caller can handle it. if rl.Keys.IsEOF() { return "", io.EOF } + // A bare async-refresh wake leaves no keys to dispatch: loop back to + // repaint the (possibly updated) UI and wait for input again. + if rl.Keys.Empty() { + continue + } + // 1 - Local keymap (Completion/Isearch/Vim operator pending). bind, command, prefixed := keymap.MatchLocal(rl.Keymap) if prefixed { diff --git a/readline/shell.go b/readline/shell.go index 205c2a1..284b92a 100644 --- a/readline/shell.go +++ b/readline/shell.go @@ -95,6 +95,7 @@ func NewShellWithTerminal(t *rlterm.Terminal, opts ...inputrc.Option) *Shell { if t.Err == nil { t.Err = t.Out } + shell := new(Shell) shell.Terminal = t @@ -125,7 +126,7 @@ func NewShellWithTerminal(t *rlterm.Terminal, opts ...inputrc.Option) *Shell { shell.Opts = opts // User interface - hint := new(ui.Hint) + hint := ui.NewHint(keys) prompt := ui.NewPrompt(line, cursor, keymaps, config) macros := macro.NewEngine(keys, hint) history := history.NewSources(line, cursor, hint, config) @@ -172,11 +173,12 @@ func (rl *Shell) Selection() *core.Selection { return rl.selection } func (rl *Shell) Printf(msg string, args ...any) (n int, err error) { restore := term.Activate(rl.Terminal.Out, rl.Terminal.Control) defer restore() + // First go back to the last line of the input line, // and clear everything below (hints and completions). rl.Display.CursorBelowLine() term.MoveCursorBackwards(term.GetWidth()) - term.Print(term.ClearScreenBelow) + term.WriteString(term.ClearScreenBelow) // Skip a line, and print the formatted message. n, err = fmt.Fprintf(rl.Terminal.Out, msg+"\n", args...) @@ -193,15 +195,15 @@ func (rl *Shell) Printf(msg string, args ...any) (n int, err error) { func (rl *Shell) PrintTransientf(msg string, args ...any) (n int, err error) { restore := term.Activate(rl.Terminal.Out, rl.Terminal.Control) defer restore() + // First go back to the beginning of the line/prompt, and // clear everything below (prompt/line/hints/completions). rl.Display.CursorToLineStart() term.MoveCursorBackwards(term.GetWidth()) term.MoveCursorUp(rl.Prompt.PrimaryUsed()) - term.Print(term.ClearScreenBelow) + term.WriteString(term.ClearScreenBelow) // Print the logged message. - // Ensure exactly one trailing newline to separate message from redrawn prompt. formatted := fmt.Sprintf(msg, args...) if !strings.HasSuffix(formatted, "\n") { formatted += "\n" @@ -215,7 +217,7 @@ func (rl *Shell) PrintTransientf(msg string, args ...any) (n int, err error) { return } -// SetInlineSuggestion sets an inline suggestion to display after the cursor (fish-style). +// SetInlineSuggestion sets an inline suggestion to display after the cursor. func (rl *Shell) SetInlineSuggestion(suggestion string) { rl.Display.SetInlineSuggestion(suggestion) } @@ -235,6 +237,7 @@ func (rl *Shell) Refresh() { if rl == nil || rl.Display == nil { return } + restore := term.Activate(rl.Terminal.Out, rl.Terminal.Control) defer restore() rl.Display.Refresh() diff --git a/readline/terminal/fork_test.go b/readline/terminal/fork_test.go new file mode 100644 index 0000000..2708156 --- /dev/null +++ b/readline/terminal/fork_test.go @@ -0,0 +1,31 @@ +package terminal + +import "testing" + +func TestStreamControlSizeAndResizeCallbacks(t *testing.T) { + control := NewControl(true, 80, 24) + if !control.IsTerminal() { + t.Fatal("control should report terminal") + } + + var gotCols, gotRows int + unregister := control.OnResize(func(cols, rows int) { + gotCols, gotRows = cols, rows + }) + + control.SetSize(120, 40) + if gotCols != 120 || gotRows != 40 { + t.Fatalf("resize callback got %dx%d, want 120x40", gotCols, gotRows) + } + + cols, rows := control.Size() + if cols != 120 || rows != 40 { + t.Fatalf("control size = %dx%d, want 120x40", cols, rows) + } + + unregister() + control.SetSize(100, 30) + if gotCols != 120 || gotRows != 40 { + t.Fatalf("resize callback fired after unregister: %dx%d", gotCols, gotRows) + } +} diff --git a/readline/vim.go b/readline/vim.go index f124b7b..6594e06 100644 --- a/readline/vim.go +++ b/readline/vim.go @@ -666,7 +666,7 @@ func (rl *Shell) viChangeChar() { switch { case rl.selection.Active() && rl.selection.IsVisual(): // In visual mode, we replace all chars of the selection - rl.selection.ReplaceWith(func(r rune) rune { + rl.selection.ReplaceWith(func(_ rune) rune { return key }) default: