├── .github └── workflows │ ├── codacy.yml │ ├── codeql.yml │ ├── dependency-review.yml │ └── go.yml ├── LICENSE ├── README.md ├── completion.go ├── completions.go ├── emacs.go ├── go.mod ├── go.sum ├── history.go ├── inputrc ├── .gitignore ├── LICENSE ├── bind.go ├── config.go ├── constants.go ├── constants_windows.go ├── example_test.go ├── gen.go ├── inputrc.go ├── inputrc_test.go ├── parse.go └── testdata │ ├── bind-missing-closing-quote.inputrc │ ├── cond.inputrc │ ├── custom.inputrc │ ├── default.inputrc │ ├── encode.inputrc │ ├── include.inputrc │ ├── invalid-editing-mode.inputrc │ ├── invalid-keymap.inputrc │ ├── ken.inputrc │ ├── macro-missing-closing-quote.inputrc │ ├── missing-colon.inputrc │ ├── set-keymap.inputrc │ └── spaces.inputrc ├── internal ├── color │ └── color.go ├── completion │ ├── completion.go │ ├── display.go │ ├── engine.go │ ├── group.go │ ├── hint.go │ ├── insert.go │ ├── isearch.go │ ├── message.go │ ├── suffix.go │ ├── syntax.go │ ├── utils.go │ └── values.go ├── core │ ├── api_windows.go │ ├── cursor.go │ ├── cursor_test.go │ ├── iterations.go │ ├── iterations_test.go │ ├── keys.go │ ├── keys_unix.go │ ├── keys_windows.go │ ├── line.go │ ├── line_test.go │ ├── selection.go │ └── selection_test.go ├── display │ ├── display_unix.go │ ├── display_windows.go │ ├── engine.go │ └── highlight.go ├── editor │ ├── buffers.go │ ├── editor.go │ ├── editor_plan9.go │ ├── editor_unix.go │ └── editor_windows.go ├── history │ ├── file.go │ ├── history.go │ ├── sources.go │ └── undo.go ├── keymap │ ├── completion.go │ ├── config.go │ ├── cursor.go │ ├── dispatch.go │ ├── emacs.go │ ├── engine.go │ ├── mode.go │ ├── pending.go │ └── vim.go ├── macro │ └── engine.go ├── strutil │ ├── key.go │ ├── keyword.go │ ├── len.go │ ├── split.go │ └── surround.go ├── term │ ├── codes.go │ ├── cursor.go │ ├── raw_bsd.go │ ├── raw_linux.go │ ├── raw_plan9.go │ ├── raw_solaris.go │ ├── raw_unix.go │ ├── raw_windows.go │ └── term.go └── ui │ ├── hint.go │ └── prompt.go ├── readline.go ├── shell.go └── vim.go /.github/workflows/codacy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow checks out code, performs a Codacy security scan 7 | # and integrates the results with the 8 | # GitHub Advanced Security code scanning feature. For more information on 9 | # the Codacy security scan action usage and parameters, see 10 | # https://github.com/codacy/codacy-analysis-cli-action. 11 | # For more information on Codacy Analysis CLI in general, see 12 | # https://github.com/codacy/codacy-analysis-cli. 13 | 14 | name: Codacy Security Scan 15 | 16 | on: 17 | push: 18 | branches: [ "master" ] 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: [ "master" ] 22 | schedule: 23 | - cron: '34 11 * * 5' 24 | 25 | permissions: 26 | contents: read 27 | 28 | jobs: 29 | codacy-security-scan: 30 | permissions: 31 | contents: read # for actions/checkout to fetch code 32 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 33 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 34 | name: Codacy Security Scan 35 | runs-on: ubuntu-latest 36 | steps: 37 | # Checkout the repository to the GitHub Actions runner 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | 41 | # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis 42 | - name: Run Codacy Analysis CLI 43 | uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b 44 | with: 45 | # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository 46 | # You can also omit the token and run the tools that support default configurations 47 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 48 | verbose: true 49 | output: results.sarif 50 | format: sarif 51 | # Adjust severity of non-security issues 52 | gh-code-scanning-compat: true 53 | # Force 0 exit code to allow SARIF file generation 54 | # This will handover control about PR rejection to the GitHub side 55 | max-allowed-issues: 2147483647 56 | 57 | # Upload the SARIF file generated in the previous step 58 | - name: Upload SARIF results file 59 | uses: github/codeql-action/upload-sarif@v3 60 | with: 61 | sarif_file: results.sarif 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '15 22 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v3 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v3 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v4 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | unix: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest] 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: 1.22.6 27 | 28 | - name: Build 29 | run: go build -v ./... 30 | 31 | - name: Run coverage 32 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v4 35 | 36 | windows: 37 | runs-on: windows-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - name: Set up Go 43 | uses: actions/setup-go@v5 44 | with: 45 | go-version: 1.22.6 46 | 47 | - name: Build 48 | run: go build -v ./... 49 | shell: powershell 50 | 51 | # - name: Run coverage 52 | # run: go test -v ./... 53 | # shell: powershell 54 | -------------------------------------------------------------------------------- /completion.go: -------------------------------------------------------------------------------- 1 | package readline 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/reeflective/readline/internal/color" 7 | "github.com/reeflective/readline/internal/completion" 8 | "github.com/reeflective/readline/internal/history" 9 | "github.com/reeflective/readline/internal/keymap" 10 | ) 11 | 12 | func (rl *Shell) completionCommands() commands { 13 | return map[string]func(){ 14 | "complete": rl.completeWord, 15 | "possible-completions": rl.possibleCompletions, 16 | "insert-completions": rl.insertCompletions, 17 | "menu-complete": rl.menuComplete, 18 | "menu-complete-backward": rl.menuCompleteBackward, 19 | "delete-char-or-list": rl.deleteCharOrList, 20 | 21 | "menu-complete-next-tag": rl.menuCompleteNextTag, 22 | "menu-complete-prev-tag": rl.menuCompletePrevTag, 23 | "accept-and-menu-complete": rl.acceptAndMenuComplete, 24 | "vi-registers-complete": rl.viRegistersComplete, 25 | "menu-incremental-search": rl.menuIncrementalSearch, 26 | } 27 | } 28 | 29 | // 30 | // Commands --------------------------------------------------------------------------- 31 | // 32 | 33 | // Attempt completion on the current word. 34 | // Currently identitical to menu-complete. 35 | func (rl *Shell) completeWord() { 36 | rl.History.SkipSave() 37 | 38 | // This completion function should attempt to insert the first 39 | // valid completion found, without printing the actual list. 40 | if !rl.completer.IsActive() { 41 | rl.startMenuComplete(rl.commandCompletion) 42 | 43 | if rl.Config.GetBool("menu-complete-display-prefix") { 44 | return 45 | } 46 | } 47 | 48 | rl.completer.Select(1, 0) 49 | rl.completer.SkipDisplay() 50 | } 51 | 52 | // List possible completions for the current word. 53 | func (rl *Shell) possibleCompletions() { 54 | rl.History.SkipSave() 55 | 56 | rl.startMenuComplete(rl.commandCompletion) 57 | } 58 | 59 | // Insert all completions for the current word into the line. 60 | func (rl *Shell) insertCompletions() { 61 | rl.History.Save() 62 | 63 | // Generate all possible completions 64 | if !rl.completer.IsActive() { 65 | rl.startMenuComplete(rl.commandCompletion) 66 | } 67 | 68 | // Insert each match, cancel insertion with preserving 69 | // the candidate just inserted in the line, for each. 70 | for i := 0; i < rl.completer.Matches(); i++ { 71 | rl.completer.Select(1, 0) 72 | rl.completer.Cancel(false, false) 73 | } 74 | 75 | // Clear the completion menu. 76 | rl.completer.Cancel(false, false) 77 | rl.completer.ClearMenu(true) 78 | } 79 | 80 | // Like complete-word, except that menu completion is used. 81 | func (rl *Shell) menuComplete() { 82 | rl.History.SkipSave() 83 | 84 | // No completions are being printed yet, so simply generate the completions 85 | // as if we just request them without immediately selecting a candidate. 86 | if !rl.completer.IsActive() { 87 | rl.startMenuComplete(rl.commandCompletion) 88 | 89 | // Immediately select only if not asked to display first. 90 | if rl.Config.GetBool("menu-complete-display-prefix") { 91 | return 92 | } 93 | } 94 | 95 | rl.completer.Select(1, 0) 96 | } 97 | 98 | // Deletes the character under the cursor if not at the 99 | // beginning or end of the line (like delete-char). 100 | // If at the end of the line, behaves identically to 101 | // possible-completions. 102 | func (rl *Shell) deleteCharOrList() { 103 | switch { 104 | case rl.cursor.Pos() < rl.line.Len(): 105 | rl.line.CutRune(rl.cursor.Pos()) 106 | default: 107 | rl.possibleCompletions() 108 | } 109 | } 110 | 111 | // Identical to menu-complete, but moves backward through the 112 | // list of possible completions, as if menu-complete had been 113 | // given a negative argument. 114 | func (rl *Shell) menuCompleteBackward() { 115 | rl.History.SkipSave() 116 | 117 | // We don't do anything when not already completing. 118 | if !rl.completer.IsActive() { 119 | rl.startMenuComplete(rl.commandCompletion) 120 | } 121 | 122 | rl.completer.Select(-1, 0) 123 | } 124 | 125 | // In a menu completion, if there are several tags 126 | // of completions, go to the first result of the next tag. 127 | func (rl *Shell) menuCompleteNextTag() { 128 | rl.History.SkipSave() 129 | 130 | if !rl.completer.IsActive() { 131 | return 132 | } 133 | 134 | rl.completer.SelectTag(true) 135 | } 136 | 137 | // In a menu completion, if there are several tags of 138 | // completions, go to the first result of the previous tag. 139 | func (rl *Shell) menuCompletePrevTag() { 140 | rl.History.SkipSave() 141 | 142 | if !rl.completer.IsActive() { 143 | return 144 | } 145 | 146 | rl.completer.SelectTag(false) 147 | } 148 | 149 | // In a menu completion, insert the current completion 150 | // into the buffer, and advance to the next possible completion. 151 | func (rl *Shell) acceptAndMenuComplete() { 152 | rl.History.SkipSave() 153 | 154 | // We don't do anything when not already completing. 155 | if !rl.completer.IsActive() { 156 | return 157 | } 158 | 159 | // Also return if no candidate 160 | if !rl.completer.IsInserting() { 161 | return 162 | } 163 | 164 | // First insert the current candidate. 165 | rl.completer.Cancel(false, false) 166 | 167 | // And cycle to the next one. 168 | rl.completer.Select(1, 0) 169 | } 170 | 171 | // Open a completion menu (similar to menu-complete) with all currently populated Vim registers. 172 | func (rl *Shell) viRegistersComplete() { 173 | rl.History.SkipSave() 174 | rl.startMenuComplete(rl.Buffers.Complete) 175 | } 176 | 177 | // In a menu completion (whether a candidate is selected or not), start incremental-search 178 | // (fuzzy search) on the results. Search backward incrementally for a specified string. 179 | // The search is case-insensitive if the search string does not have uppercase letters 180 | // and no numeric argument was given. The string may begin with ‘^’ to anchor the search 181 | // to the beginning of the line. A restricted set of editing functions is available in the 182 | // mini-buffer. Keys are looked up in the special isearch keymap, On each change in the 183 | // mini-buffer, any currently selected candidate is dropped from the line and the menu. 184 | // An interrupt signal, as defined by the stty setting, will stop the search and go back to the original line. 185 | func (rl *Shell) menuIncrementalSearch() { 186 | rl.History.SkipSave() 187 | 188 | // Always regenerate the list of completions. 189 | rl.completer.GenerateWith(rl.commandCompletion) 190 | rl.completer.IsearchStart("completions", false, false) 191 | } 192 | 193 | // 194 | // Utilities -------------------------------------------------------------------------- 195 | // 196 | 197 | // startMenuComplete generates a completion menu with completions 198 | // generated from a given completer, without selecting a candidate. 199 | func (rl *Shell) startMenuComplete(completer completion.Completer) { 200 | rl.History.SkipSave() 201 | 202 | rl.Keymap.SetLocal(keymap.MenuSelect) 203 | rl.completer.GenerateWith(completer) 204 | } 205 | 206 | // commandCompletion generates the completions for commands/args/flags. 207 | func (rl *Shell) commandCompletion() completion.Values { 208 | if rl.Completer == nil { 209 | return completion.Values{} 210 | } 211 | 212 | line, cursor := rl.completer.Line() 213 | comps := rl.Completer(*line, cursor.Pos()) 214 | 215 | return comps.convert() 216 | } 217 | 218 | // historyCompletion manages the various completion/isearch modes related 219 | // to history control. It can start the history completions, stop them, cycle 220 | // through sources if more than one, and adjust the completion/isearch behavior. 221 | func (rl *Shell) historyCompletion(forward, filterLine, substring bool) { 222 | switch { 223 | case rl.Keymap.Local() == keymap.MenuSelect || rl.Keymap.Local() == keymap.Isearch || rl.completer.AutoCompleting(): 224 | // If we are currently completing the last 225 | // history source, cancel history completion. 226 | if rl.History.OnLastSource() { 227 | rl.History.Cycle(true) 228 | rl.completer.ResetForce() 229 | rl.Hint.Reset() 230 | 231 | return 232 | } 233 | 234 | // Else complete the next history source. 235 | rl.History.Cycle(true) 236 | 237 | fallthrough 238 | 239 | default: 240 | // Notify if we don't have history sources at all. 241 | if rl.History.Current() == nil { 242 | rl.Hint.SetTemporary(fmt.Sprintf("%s%s%s %s", color.Dim, color.FgRed, "No command history source", color.Reset)) 243 | return 244 | } 245 | 246 | // Generate the completions with specified behavior. 247 | completer := func() completion.Values { 248 | maxLines := rl.Display.AvailableHelperLines() 249 | return history.Complete(rl.History, forward, filterLine, maxLines, rl.completer.IsearchRegex) 250 | } 251 | 252 | if substring { 253 | rl.completer.GenerateWith(completer) 254 | rl.completer.IsearchStart(rl.History.Name(), true, true) 255 | } else { 256 | rl.startMenuComplete(completer) 257 | rl.completer.AutocompleteForce() 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/reeflective/readline 2 | 3 | go 1.23.6 4 | 5 | require ( 6 | golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 7 | golang.org/x/sys v0.8.0 8 | golang.org/x/term v0.8.0 9 | ) 10 | 11 | require github.com/rivo/uniseg v0.4.4 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 2 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 3 | golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= 4 | golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 5 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 6 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 7 | golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= 8 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 9 | -------------------------------------------------------------------------------- /inputrc/.gitignore: -------------------------------------------------------------------------------- 1 | *.txt 2 | -------------------------------------------------------------------------------- /inputrc/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2023 Kenneth Shaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /inputrc/config.go: -------------------------------------------------------------------------------- 1 | package inputrc 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // Handler is the handler interface. 8 | type Handler interface { 9 | // ReadFile reads a file. 10 | ReadFile(name string) ([]byte, error) 11 | // Do handles $constructs. 12 | Do(typ string, param string) error 13 | // Set sets the value. 14 | Set(name string, value interface{}) error 15 | // Get gets the value. 16 | Get(name string) interface{} 17 | // Bind binds a key sequence to an action for the current keymap. 18 | Bind(keymap, sequence, action string, macro bool) error 19 | } 20 | 21 | // Config is a inputrc config handler. 22 | type Config struct { 23 | ReadFileFunc func(string) ([]byte, error) 24 | Vars map[string]interface{} 25 | Binds map[string]map[string]Bind 26 | Funcs map[string]func(string, string) error 27 | } 28 | 29 | // NewConfig creates a new inputrc config. 30 | func NewConfig() *Config { 31 | return &Config{ 32 | Vars: make(map[string]interface{}), 33 | Binds: make(map[string]map[string]Bind), 34 | Funcs: make(map[string]func(string, string) error), 35 | } 36 | } 37 | 38 | // NewDefaultConfig creates a new inputrc config with default values. 39 | func NewDefaultConfig(opts ...ConfigOption) *Config { 40 | cfg := &Config{ 41 | ReadFileFunc: os.ReadFile, 42 | Vars: DefaultVars(), 43 | Binds: DefaultBinds(), 44 | Funcs: make(map[string]func(string, string) error), 45 | } 46 | for _, o := range opts { 47 | o(cfg) 48 | } 49 | 50 | return cfg 51 | } 52 | 53 | // ReadFile satisfies the Handler interface. 54 | func (cfg *Config) ReadFile(name string) ([]byte, error) { 55 | if cfg.ReadFileFunc != nil { 56 | return cfg.ReadFileFunc(name) 57 | } 58 | 59 | return nil, os.ErrNotExist 60 | } 61 | 62 | // Do satisfies the Handler interface. 63 | func (cfg *Config) Do(name, value string) error { 64 | if f, ok := cfg.Funcs[name]; ok { 65 | return f(name, value) 66 | } 67 | 68 | if f, ok := cfg.Funcs[""]; ok { 69 | return f(name, value) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // Get satisfies the Handler interface. 76 | func (cfg *Config) Get(name string) interface{} { 77 | return cfg.Vars[name] 78 | } 79 | 80 | // Set satisfies the Handler interface. 81 | func (cfg *Config) Set(name string, value interface{}) error { 82 | cfg.Vars[name] = value 83 | return nil 84 | } 85 | 86 | // Bind satisfies the Handler interface. 87 | func (cfg *Config) Bind(keymap, sequence, action string, macro bool) error { 88 | if cfg.Binds[keymap] == nil { 89 | cfg.Binds[keymap] = make(map[string]Bind) 90 | } 91 | 92 | cfg.Binds[keymap][sequence] = Bind{ 93 | Action: action, 94 | Macro: macro, 95 | } 96 | 97 | return nil 98 | } 99 | 100 | // GetString returns the var name as a string. 101 | func (cfg *Config) GetString(name string) string { 102 | if v, ok := cfg.Vars[name]; ok { 103 | if s, ok := v.(string); ok { 104 | return s 105 | } 106 | } 107 | 108 | return "" 109 | } 110 | 111 | // GetInt returns the var name as a int. 112 | func (cfg *Config) GetInt(name string) int { 113 | if v, ok := cfg.Vars[name]; ok { 114 | if i, ok := v.(int); ok { 115 | return i 116 | } 117 | } 118 | 119 | return 0 120 | } 121 | 122 | // GetBool returns the var name as a bool. 123 | func (cfg *Config) GetBool(name string) bool { 124 | if v, ok := cfg.Vars[name]; ok { 125 | if b, ok := v.(bool); ok { 126 | return b 127 | } 128 | } 129 | 130 | return false 131 | } 132 | 133 | // Bind represents a key binding. 134 | type Bind struct { 135 | Action string 136 | Macro bool 137 | } 138 | 139 | // ConfigOption is a inputrc config handler option. 140 | type ConfigOption func(*Config) 141 | 142 | // WithConfigReadFileFunc is a inputrc config option to set the func used 143 | // for ReadFile operations. 144 | func WithConfigReadFileFunc(readFileFunc func(string) ([]byte, error)) ConfigOption { 145 | return func(cfg *Config) { 146 | cfg.ReadFileFunc = readFileFunc 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /inputrc/constants.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package inputrc 5 | 6 | const delimiter = "####----####\n" 7 | -------------------------------------------------------------------------------- /inputrc/constants_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package inputrc 5 | 6 | const delimiter = "####----####\n" 7 | -------------------------------------------------------------------------------- /inputrc/example_test.go: -------------------------------------------------------------------------------- 1 | package inputrc_test 2 | 3 | import ( 4 | "fmt" 5 | "os/user" 6 | "strings" 7 | 8 | "github.com/reeflective/readline/inputrc" 9 | ) 10 | 11 | func Example() { 12 | u, err := user.Current() 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | cfg := inputrc.NewDefaultConfig() 18 | if err := inputrc.UserDefault(u, cfg, inputrc.WithApp("bash")); err != nil { 19 | panic(err) 20 | } 21 | // Output: 22 | } 23 | 24 | func ExampleParse() { 25 | const example = ` 26 | set editing-mode vi 27 | $if Usql 28 | set keymap vi-insert 29 | "\r": a-usql-action 30 | "\d": 'echo test\n' 31 | $endif 32 | 33 | ` 34 | 35 | cfg := inputrc.NewDefaultConfig() 36 | if err := inputrc.Parse(strings.NewReader(example), cfg, inputrc.WithApp("usql")); err != nil { 37 | panic(err) 38 | } 39 | 40 | fmt.Println("editing mode:", cfg.GetString("editing-mode")) 41 | fmt.Println("vi-insert:") 42 | fmt.Printf(" %s: %s\n", inputrc.Escape(string(inputrc.Return)), cfg.Binds["vi-insert"][string(inputrc.Return)].Action) 43 | fmt.Printf(" %s: '%s'\n", inputrc.Escape(string(inputrc.Delete)), inputrc.EscapeMacro(cfg.Binds["vi-insert"][string(inputrc.Delete)].Action)) 44 | // Output: 45 | // editing mode: vi 46 | // vi-insert: 47 | // \C-M: a-usql-action 48 | // \C-?: 'echo test\n' 49 | } 50 | -------------------------------------------------------------------------------- /inputrc/gen.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "go/format" 12 | "io" 13 | "os" 14 | "os/exec" 15 | "strconv" 16 | "strings" 17 | "unicode/utf8" 18 | ) 19 | 20 | func main() { 21 | out := flag.String("out", "bind.go", "out") 22 | dump := flag.Bool("dump", false, "dump") 23 | flag.Parse() 24 | if err := run(*out, *dump); err != nil { 25 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 26 | os.Exit(1) 27 | } 28 | } 29 | 30 | func run(out string, dump bool) error { 31 | buf := new(bytes.Buffer) 32 | if _, err := buf.Write([]byte(hdr)); err != nil { 33 | return err 34 | } 35 | if err := loadVars(buf); err != nil { 36 | return err 37 | } 38 | if err := loadKeymaps(buf); err != nil { 39 | return err 40 | } 41 | if dump { 42 | return os.WriteFile(out, buf.Bytes(), 0o644) 43 | } 44 | b, err := format.Source(buf.Bytes()) 45 | if err != nil { 46 | return err 47 | } 48 | return os.WriteFile(out, b, 0o644) 49 | } 50 | 51 | func loadVars(w io.Writer) error { 52 | fmt.Fprintln(w, "// DefaultVars are the default readline vars.") 53 | fmt.Fprintln(w, "//") 54 | fmt.Fprintln(w, "// see: INPUTRC=/dev/null bash -c 'bind -v'") 55 | fmt.Fprintln(w, "func DefaultVars() map[string]interface{} {") 56 | fmt.Fprintln(w, "\treturn map[string]interface{}{") 57 | s, err := load("bash", "-c", "bind -v") 58 | if err != nil { 59 | return err 60 | } 61 | for s.Scan() { 62 | v := strings.SplitN(strings.TrimSpace(s.Text()), " ", 3) 63 | var val interface{} = v[2] 64 | typ := "q" 65 | switch v[2] { 66 | case "on": 67 | typ, val = "t", true 68 | case "off": 69 | typ, val = "t", false 70 | default: 71 | if i, err := strconv.Atoi(v[2]); err == nil { 72 | typ, val = "d", i 73 | } 74 | } 75 | fmt.Fprintf(w, "\t\t%q: %"+typ+",\n", v[1], val) 76 | } 77 | fmt.Fprintln(w, "\t}") 78 | fmt.Fprintln(w, "}") 79 | if err := s.Err(); err != nil && !errors.Is(err, io.EOF) { 80 | return err 81 | } 82 | return nil 83 | } 84 | 85 | func loadKeymaps(w io.Writer) error { 86 | fmt.Fprintln(w, "// DefaultBinds are the default readline bind keymaps.") 87 | fmt.Fprintln(w, "//") 88 | fmt.Fprintln(w, "// see: INPUTRC=/dev/null bash -c 'bind -pm '") 89 | fmt.Fprintln(w, "func DefaultBinds() map[string]map[string]Bind {") 90 | fmt.Fprintln(w, "\treturn map[string]map[string]Bind {") 91 | for _, keymap := range []string{ 92 | "emacs", "emacs-standard", "emacs-meta", "emacs-ctlx", 93 | "vi", "vi-move", "vi-command", "vi-insert", 94 | } { 95 | fmt.Fprintf(w, "\t\t%q: map[string]Bind {\n", keymap) 96 | s, err := load("bash", "-c", "bind -pm "+keymap) 97 | if err != nil { 98 | return fmt.Errorf("unable to load keymap %s: %w", keymap, err) 99 | } 100 | m := make(map[string]bool) 101 | for s.Scan() { 102 | line := strings.TrimSpace(s.Text()) 103 | if strings.HasPrefix(line, "# ") { 104 | fmt.Fprintln(w, "\t\t\t// "+strings.TrimPrefix(line, "# ")) 105 | continue 106 | } 107 | v := strings.SplitN(line, `": `, 2) 108 | z := strings.TrimPrefix(v[0], `"`) 109 | switch b := []byte(z); { 110 | case !utf8.Valid(b): 111 | z = `string([]byte{` + encode(b) + `})` 112 | case z == "`": 113 | z = "\"`\"" 114 | default: 115 | z = "`" + z + "`" 116 | } 117 | if !m[z] { 118 | fmt.Fprintf(w, "\t\t\tUnescape(%s): Bind{%q, false},\n", z, v[1]) 119 | } else { 120 | fmt.Fprintf(w, "\t\t\t// Unescape(%s): Bind{%q, false}, // DUPLICATE KEY\n", z, v[1]) 121 | } 122 | m[z] = true 123 | } 124 | if err := s.Err(); err != nil && !errors.Is(err, io.EOF) { 125 | return fmt.Errorf("unable to read keymap %s: %w", keymap, err) 126 | } 127 | fmt.Fprintln(w, "\t\t},") 128 | } 129 | fmt.Fprintln(w, "\t}") 130 | fmt.Fprintln(w, "}") 131 | return nil 132 | } 133 | 134 | func load(name string, params ...string) (*bufio.Scanner, error) { 135 | buf := new(bytes.Buffer) 136 | cmd := exec.Command(name, params...) 137 | cmd.Env = []string{"INPUTRC=/dev/null"} 138 | cmd.Stdout = buf 139 | cmd.Stderr = os.Stderr 140 | if err := cmd.Run(); err != nil { 141 | return nil, err 142 | } 143 | return bufio.NewScanner(bytes.NewReader(bytes.TrimSpace(buf.Bytes()))), nil 144 | } 145 | 146 | func encode(b []byte) string { 147 | var s []string 148 | for _, c := range b { 149 | s = append(s, fmt.Sprintf("0x%x", c)) 150 | } 151 | return strings.Join(s, ", ") 152 | } 153 | 154 | const hdr = `package inputrc 155 | 156 | // Generated by gen.go. DO NOT EDIT. 157 | 158 | ` 159 | -------------------------------------------------------------------------------- /inputrc/inputrc.go: -------------------------------------------------------------------------------- 1 | // Package inputrc parses readline inputrc files. 2 | package inputrc 3 | 4 | //go:generate go run gen.go 5 | 6 | import ( 7 | "bytes" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "os" 12 | "os/user" 13 | "path/filepath" 14 | "runtime" 15 | "strings" 16 | "unicode" 17 | ) 18 | 19 | // Parse parses inputrc data from r. 20 | func Parse(r io.Reader, h Handler, opts ...Option) error { 21 | return New(opts...).Parse(r, h) 22 | } 23 | 24 | // ParseBytes parses inputrc data from buf. 25 | func ParseBytes(buf []byte, h Handler, opts ...Option) error { 26 | return New(opts...).Parse(bytes.NewReader(buf), h) 27 | } 28 | 29 | // ParseFile parses inputrc data from a file name. 30 | func ParseFile(name string, h Handler, opts ...Option) error { 31 | f, err := os.Open(name) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | defer f.Close() 37 | 38 | return New(append(opts, WithName(name))...).Parse(f, h) 39 | } 40 | 41 | // UserDefault loads default inputrc settings for the user. 42 | func UserDefault(u *user.User, cfg *Config, opts ...Option) error { 43 | // build possible file list 44 | var files []string 45 | if name := os.Getenv("INPUTRC"); name != "" { 46 | files = append(files, name) 47 | } 48 | 49 | if u != nil { 50 | name := ".inputrc" 51 | if runtime.GOOS == "windows" { 52 | name = "_inputrc" 53 | } 54 | 55 | files = append(files, filepath.Join(u.HomeDir, name)) 56 | } 57 | 58 | if runtime.GOOS != "windows" { 59 | files = append(files, "/etc/inputrc") 60 | } 61 | // load first available file 62 | for _, name := range files { 63 | buf, err := cfg.ReadFile(name) 64 | 65 | switch { 66 | case err != nil && errors.Is(err, os.ErrNotExist): 67 | continue 68 | case err != nil: 69 | return err 70 | } 71 | 72 | return ParseBytes(buf, cfg, append(opts, WithName(name))...) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // Unescape unescapes a inputrc string. 79 | func Unescape(s string) string { 80 | r := []rune(s) 81 | return unescapeRunes(r, 0, len(r)) 82 | } 83 | 84 | // Escape escapes a inputrc string. 85 | func Escape(s string) string { 86 | return escape(s, map[rune]string{ 87 | Delete: `\C-?`, 88 | Return: `\C-M`, 89 | }) 90 | } 91 | 92 | // EscapeMacro escapes a inputrc macro. 93 | func EscapeMacro(s string) string { 94 | return escape(s, map[rune]string{ 95 | Delete: `\d`, 96 | Return: `\r`, 97 | }) 98 | } 99 | 100 | // escape escapes s using m. 101 | func escape(s string, m map[rune]string) string { 102 | var v []string 103 | 104 | for _, c := range s { 105 | switch c { 106 | case Alert: 107 | v = append(v, `\a`) 108 | case Backspace: 109 | v = append(v, `\b`) 110 | case Delete: 111 | v = append(v, m[Delete]) // \C-? or \d 112 | case Esc: 113 | v = append(v, `\e`) 114 | case Formfeed: 115 | v = append(v, `\f`) 116 | case Newline: 117 | v = append(v, `\n`) 118 | case Return: 119 | v = append(v, m[Return]) // \C-M or \r 120 | case Tab: 121 | v = append(v, `\t`) 122 | case Vertical: 123 | v = append(v, `\v`) 124 | case '\\', '"', '\'': 125 | v = append(v, `\`+string(c)) 126 | default: 127 | var s string 128 | if IsControl(c) { 129 | s += `\C-` 130 | c = Decontrol(c) 131 | } 132 | 133 | if IsMeta(c) { 134 | s += `\M-` 135 | c = Demeta(c) 136 | } 137 | 138 | if unicode.IsPrint(c) { 139 | s += string(c) 140 | } else { 141 | s += fmt.Sprintf(`\x%2x`, c) 142 | } 143 | 144 | v = append(v, s) 145 | } 146 | } 147 | 148 | return strings.Join(v, "") 149 | } 150 | 151 | // Encontrol encodes a Control-c code. 152 | func Encontrol(c rune) rune { 153 | return unicode.ToUpper(c) & Control 154 | } 155 | 156 | // Decontrol decodes a Control-c code. 157 | func Decontrol(c rune) rune { 158 | return unicode.ToUpper(c | 0x40) 159 | } 160 | 161 | // IsControl returns true when c is a Control-c code. 162 | func IsControl(c rune) bool { 163 | return c < Space && c&Meta == 0 164 | } 165 | 166 | // Enmeta encodes a Meta-c code. 167 | func Enmeta(c rune) rune { 168 | return c | Meta 169 | } 170 | 171 | // Demeta decodes a Meta-c code. 172 | func Demeta(c rune) rune { 173 | return c & ^Meta 174 | } 175 | 176 | // IsMeta returns true when c is a Meta-c code. 177 | func IsMeta(c rune) bool { 178 | return c > Delete && c <= 0xff 179 | } 180 | 181 | // Error is a error. 182 | type Error string 183 | 184 | // Errors. 185 | const ( 186 | // ErrBindMissingClosingQuote is the bind missing closing quote error. 187 | ErrBindMissingClosingQuote Error = `bind missing closing quote` 188 | // ErrMissingColon is the missing : error. 189 | ErrMissingColon Error = "missing :" 190 | // ErrMacroMissingClosingQuote is the macro missing closing quote error. 191 | ErrMacroMissingClosingQuote Error = `macro missing closing quote` 192 | // ErrInvalidKeymap is the invalid keymap error. 193 | ErrInvalidKeymap Error = "invalid keymap" 194 | // ErrInvalidEditingMode is the invalid editing mode error. 195 | ErrInvalidEditingMode Error = "invalid editing mode" 196 | // ErrElseWithoutMatchingIf is the $else without matching $if error. 197 | ErrElseWithoutMatchingIf Error = "$else without matching $if" 198 | // ErrEndifWithoutMatchingIf is the $endif without matching $if error. 199 | ErrEndifWithoutMatchingIf Error = "$endif without matching $if" 200 | // ErrUnknownModifier is the unknown modifier error. 201 | ErrUnknownModifier Error = "unknown modifier" 202 | ) 203 | 204 | // Error satisfies the error interface. 205 | func (err Error) Error() string { 206 | return string(err) 207 | } 208 | 209 | // Keys. 210 | const ( 211 | Control rune = 0x1f 212 | Meta rune = 0x80 213 | Esc rune = 0x1b 214 | Delete rune = 0x7f 215 | Alert rune = '\a' 216 | Backspace rune = '\b' 217 | Formfeed rune = '\f' 218 | Newline rune = '\n' 219 | Return rune = '\r' 220 | Tab rune = '\t' 221 | Vertical rune = '\v' 222 | Space rune = ' ' 223 | // Rubout = Delete. 224 | ) 225 | -------------------------------------------------------------------------------- /inputrc/testdata/bind-missing-closing-quote.inputrc: -------------------------------------------------------------------------------- 1 | haltOnErr: true 2 | ####----#### 3 | "fo\" 4 | ####----#### 5 | error: bind missing closing quote 6 | -------------------------------------------------------------------------------- /inputrc/testdata/cond.inputrc: -------------------------------------------------------------------------------- 1 | app: bash 2 | term: xterm-256 3 | mode: vi 4 | ####----#### 5 | set editing-mode vi 6 | 7 | $if bash 8 | set foo on 9 | $endif 10 | 11 | $if usql 12 | $else 13 | $if mode=vi 14 | set one two 15 | $endif 16 | $endif 17 | ####----#### 18 | vars: 19 | editing-mode: vi 20 | foo: true 21 | one: two 22 | -------------------------------------------------------------------------------- /inputrc/testdata/custom.inputrc: -------------------------------------------------------------------------------- 1 | app: usql 2 | term: xterm-256 3 | ####----#### 4 | $unknown one two 5 | $custom foo bar 6 | ####----#### 7 | $custom: 8 | foo 9 | $unknown: 10 | one 11 | -------------------------------------------------------------------------------- /inputrc/testdata/default.inputrc: -------------------------------------------------------------------------------- 1 | term: rxvt 2 | app: bash 3 | ####----#### 4 | # /etc/inputrc - global inputrc for libreadline 5 | # See readline(3readline) and `info rluserman' for more information. 6 | 7 | # Be 8 bit clean. 8 | set input-meta on 9 | set output-meta on 10 | 11 | # To allow the use of 8bit-characters like the german umlauts, uncomment 12 | # the line below. However this makes the meta key not work as a meta key, 13 | # which is annoying to those which don't need to type in 8-bit characters. 14 | 15 | # set convert-meta off 16 | 17 | # try to enable the application keypad when it is called. Some systems 18 | # need this to enable the arrow keys. 19 | set enable-keypad on 20 | 21 | # see /usr/share/doc/bash/inputrc.arrows for other codes of arrow keys 22 | 23 | # do not bell on tab-completion 24 | # set bell-style none 25 | # set bell-style visible 26 | 27 | # some defaults / modifications for the emacs mode 28 | $if mode=emacs 29 | 30 | # allow the use of the Home/End keys 31 | "\e[1~": beginning-of-line 32 | "\e[4~": end-of-line 33 | 34 | # allow the use of the Delete/Insert keys 35 | "\e[3~": delete-char 36 | "\e[2~": quoted-insert 37 | 38 | # mappings for "page up" and "page down" to step to the beginning/end 39 | # of the history 40 | # "\e[5~": beginning-of-history 41 | # "\e[6~": end-of-history 42 | 43 | # alternate mappings for "page up" and "page down" to search the history 44 | # "\e[5~": history-search-backward 45 | # "\e[6~": history-search-forward 46 | 47 | # mappings for Ctrl-left-arrow and Ctrl-right-arrow for word moving 48 | "\e[1;5C": forward-word 49 | "\e[1;5D": backward-word 50 | "\e[5C": forward-word 51 | "\e[5D": backward-word 52 | "\e\e[C": forward-word 53 | "\e\e[D": backward-word 54 | 55 | $if term=rxvt 56 | "\e[7~": beginning-of-line 57 | "\e[8~": end-of-line 58 | "\eOc": forward-word 59 | "\eOd": backward-word 60 | $endif 61 | 62 | # for non RH/Debian xterm, can't hurt for RH/Debian xterm 63 | # "\eOH": beginning-of-line 64 | # "\eOF": end-of-line 65 | 66 | # for freebsd console 67 | # "\e[H": beginning-of-line 68 | # "\e[F": end-of-line 69 | 70 | $endif 71 | ####----#### 72 | vars: 73 | enable-keypad: true 74 | input-meta: true 75 | output-meta: true 76 | binds: 77 | emacs: 78 | \eOc: forward-word 79 | \eOd: backward-word 80 | \e[7~: beginning-of-line 81 | \e[8~: end-of-line 82 | -------------------------------------------------------------------------------- /inputrc/testdata/encode.inputrc: -------------------------------------------------------------------------------- 1 | app: usql 2 | term: xterm-256 3 | ####----#### 4 | "\M-y": some-action 5 | "\M-\C-U": "\M-j" 6 | "\M-k": "\M-\C-0" 7 | "\C-e": 'blah\a\b\f\t\n\r\v\"\'' 8 | "\C-f": "blah\a\b\f\t\n\r\v\"\'" 9 | "\d": "\C-?" 10 | Control-z: "\M-\C-i" 11 | Control-r: '\C-x' 12 | "\r": "\C-m" 13 | ####----#### 14 | binds: 15 | emacs: 16 | \C-E: "blah\a\b\f\t\n\r\v\"\'" 17 | \C-F: "blah\a\b\f\t\n\r\v\"\'" 18 | \C-M: "\r" 19 | \C-R: "\C-X" 20 | \C-Z: "\e\t" 21 | \e\C-U: "\M-j" 22 | \C-?: "\d" 23 | \M-k: "\e\C-P" 24 | \M-y: some-action 25 | -------------------------------------------------------------------------------- /inputrc/testdata/include.inputrc: -------------------------------------------------------------------------------- 1 | app: usql 2 | term: xterm-256 3 | mode: vi 4 | ####----#### 5 | $if Usql 6 | $include ken.inputrc 7 | $endif 8 | ####----#### 9 | vars: 10 | bell-style: visible 11 | completion-map-case: true 12 | completion-query-items: 2000 13 | convert-meta: false 14 | editing-mode: vi 15 | binds: 16 | vi-command: 17 | d: backward-char 18 | h: next-history 19 | n: forward-char 20 | t: previous-history 21 | -------------------------------------------------------------------------------- /inputrc/testdata/invalid-editing-mode.inputrc: -------------------------------------------------------------------------------- 1 | haltOnErr: true 2 | ####----#### 3 | set editing-mode foo 4 | ####----#### 5 | error: invalid editing mode 6 | -------------------------------------------------------------------------------- /inputrc/testdata/invalid-keymap.inputrc: -------------------------------------------------------------------------------- 1 | haltOnErr: true 2 | strict: true 3 | ####----#### 4 | set keymap foo 5 | ####----#### 6 | error: invalid keymap 7 | -------------------------------------------------------------------------------- /inputrc/testdata/ken.inputrc: -------------------------------------------------------------------------------- 1 | app: usql 2 | term: xterm-256 3 | mode: vi 4 | ####----#### 5 | set editing-mode vi 6 | 7 | $if Usql 8 | set bell-style visible 9 | set completion-query-items 2000 10 | set completion-map-case On 11 | set convert-meta off 12 | $else 13 | set blink-matching-paren on 14 | $endif 15 | 16 | # look these up using bind -p 17 | $if mode=vi 18 | 19 | set keymap vi-command 20 | "d": backward-char 21 | "h": next-history 22 | "t": previous-history 23 | "n": forward-char 24 | 25 | $endif 26 | ####----#### 27 | vars: 28 | bell-style: visible 29 | completion-map-case: true 30 | completion-query-items: 2000 31 | convert-meta: false 32 | editing-mode: vi 33 | binds: 34 | vi-command: 35 | d: backward-char 36 | h: next-history 37 | n: forward-char 38 | t: previous-history 39 | -------------------------------------------------------------------------------- /inputrc/testdata/macro-missing-closing-quote.inputrc: -------------------------------------------------------------------------------- 1 | haltOnErr: true 2 | ####----#### 3 | "fo\"": '\' 4 | ####----#### 5 | error: macro missing closing quote 6 | -------------------------------------------------------------------------------- /inputrc/testdata/missing-colon.inputrc: -------------------------------------------------------------------------------- 1 | haltOnErr: true 2 | ####----#### 3 | Control-z 4 | ####----#### 5 | error: .*missing.*:.* 6 | -------------------------------------------------------------------------------- /inputrc/testdata/set-keymap.inputrc: -------------------------------------------------------------------------------- 1 | haltOnErr: true 2 | strict: false 3 | ####----#### 4 | set keymap foo 5 | ####----#### 6 | -------------------------------------------------------------------------------- /inputrc/testdata/spaces.inputrc: -------------------------------------------------------------------------------- 1 | app: usql 2 | term: xterm-256 3 | mode: vi 4 | ####----#### 5 | set editing-mode vi 6 | set vi-ins-mode-string "\1\e[4 q\2" 7 | set my-other-string 'a b' 8 | ####----#### 9 | vars: 10 | editing-mode: vi 11 | my-other-string: 'a b' 12 | vi-ins-mode-string: "\1\e[4 q\2" 13 | -------------------------------------------------------------------------------- /internal/color/color.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // Base text effects. 11 | var ( 12 | Reset = "\x1b[0m" 13 | Bold = "\x1b[1m" 14 | Dim = "\x1b[2m" 15 | Underscore = "\x1b[4m" 16 | Blink = "\x1b[5m" 17 | Reverse = "\x1b[7m" 18 | 19 | // Effects reset. 20 | BoldReset = "\x1b[22m" // 21 actually causes underline instead 21 | DimReset = "\x1b[22m" 22 | UnderscoreReset = "\x1b[24m" 23 | BlinkReset = "\x1b[25m" 24 | ReverseReset = "\x1b[27m" 25 | ) 26 | 27 | // Text colours. 28 | var ( 29 | FgBlack = "\x1b[30m" 30 | FgRed = "\x1b[31m" 31 | FgGreen = "\x1b[32m" 32 | FgYellow = "\x1b[33m" 33 | FgBlue = "\x1b[34m" 34 | FgMagenta = "\x1b[35m" 35 | FgCyan = "\x1b[36m" 36 | FgWhite = "\x1b[37m" 37 | FgDefault = "\x1b[39m" 38 | 39 | FgBlackBright = "\x1b[1;30m" 40 | FgRedBright = "\x1b[1;31m" 41 | FgGreenBright = "\x1b[1;32m" 42 | FgYellowBright = "\x1b[1;33m" 43 | FgBlueBright = "\x1b[1;34m" 44 | FgMagentaBright = "\x1b[1;35m" 45 | FgCyanBright = "\x1b[1;36m" 46 | FgWhiteBright = "\x1b[1;37m" 47 | ) 48 | 49 | // Background colours. 50 | var ( 51 | BgBlack = "\x1b[40m" 52 | BgRed = "\x1b[41m" 53 | BgGreen = "\x1b[42m" 54 | BgYellow = "\x1b[43m" 55 | BgBlue = "\x1b[44m" 56 | BgMagenta = "\x1b[45m" 57 | BgCyan = "\x1b[46m" 58 | BgWhite = "\x1b[47m" 59 | BgDefault = "\x1b[49m" 60 | 61 | BgDarkGray = "\x1b[100m" 62 | BgBlueLight = "\x1b[104m" 63 | 64 | BgBlackBright = "\x1b[1;40m" 65 | BgRedBright = "\x1b[1;41m" 66 | BgGreenBright = "\x1b[1;42m" 67 | BgYellowBright = "\x1b[1;43m" 68 | BgBlueBright = "\x1b[1;44m" 69 | BgMagentaBright = "\x1b[1;45m" 70 | BgCyanBright = "\x1b[1;46m" 71 | BgWhiteBright = "\x1b[1;47m" 72 | ) 73 | 74 | // Text effects. 75 | const ( 76 | SGRStart = "\x1b[" 77 | Fg = "38;05;" 78 | Bg = "48;05;" 79 | SGREnd = "m" 80 | ) 81 | 82 | // Fmt formats a color code as an ANSI escaped color sequence. 83 | func Fmt(color string) string { 84 | return SGRStart + color + SGREnd 85 | } 86 | 87 | // Trim accepts a string including arbitrary escaped sequences at arbitrary 88 | // index positions, and returns the first 'n' printable characters in this 89 | // string, including all escape codes found between and immediately around 90 | // those characters (including surrounding 1st and 80th ones). 91 | func Trim(input string, maxPrintableLength int) string { 92 | if len(input) < maxPrintableLength { 93 | return input 94 | } 95 | 96 | // Find all escape sequences in the input 97 | escapeIndices := re.FindAllStringIndex(input, -1) 98 | 99 | // Iterate over escape sequences to find the 100 | // last escape index within maxPrintableLength 101 | for _, indices := range escapeIndices { 102 | if indices[0] <= maxPrintableLength { 103 | maxPrintableLength += indices[1] - indices[0] 104 | } else { 105 | break 106 | } 107 | } 108 | 109 | // Determine the end index for limiting printable content 110 | return input[:maxPrintableLength] 111 | } 112 | 113 | // UnquoteRC removes the `\e` escape used in readline .inputrc 114 | // configuration values and replaces it with the printable escape. 115 | func UnquoteRC(color string) string { 116 | color = strings.ReplaceAll(color, `\e`, "\x1b") 117 | 118 | if unquoted, err := strconv.Unquote(color); err == nil { 119 | return unquoted 120 | } 121 | 122 | return color 123 | } 124 | 125 | // HasEffects returns true if colors and effects are supported 126 | // on the current terminal. 127 | func HasEffects() bool { 128 | if term := os.Getenv("TERM"); term == "" { 129 | return false 130 | } else if term == "dumb" { 131 | return false 132 | } 133 | 134 | return true 135 | } 136 | 137 | // DisableEffects will disable all colors and effects. 138 | func DisableEffects() { 139 | // Effects 140 | Reset = "" 141 | Bold = "" 142 | Dim = "" 143 | Underscore = "" 144 | Blink = "" 145 | BoldReset = "" 146 | DimReset = "" 147 | UnderscoreReset = "" 148 | BlinkReset = "" 149 | 150 | // Foreground colors 151 | FgBlack = "" 152 | FgRed = "" 153 | FgGreen = "" 154 | FgYellow = "" 155 | FgBlue = "" 156 | FgMagenta = "" 157 | FgCyan = "" 158 | FgWhite = "" 159 | FgDefault = "" 160 | 161 | FgBlackBright = "" 162 | FgRedBright = "" 163 | FgGreenBright = "" 164 | FgYellowBright = "" 165 | FgBlueBright = "" 166 | FgMagentaBright = "" 167 | FgCyanBright = "" 168 | FgWhiteBright = "" 169 | 170 | // Background colours 171 | BgBlack = "" 172 | BgRed = "" 173 | BgGreen = "" 174 | BgYellow = "" 175 | BgBlue = "" 176 | BgMagenta = "" 177 | BgCyan = "" 178 | BgWhite = "" 179 | BgDefault = "" 180 | 181 | BgDarkGray = "" 182 | BgBlueLight = "" 183 | 184 | BgBlackBright = "" 185 | BgRedBright = "" 186 | BgGreenBright = "" 187 | BgYellowBright = "" 188 | BgBlueBright = "" 189 | BgMagentaBright = "" 190 | BgCyanBright = "" 191 | BgWhiteBright = "" 192 | } 193 | 194 | const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 195 | 196 | var re = regexp.MustCompile(ansi) 197 | 198 | // Strip removes all ANSI escaped color sequences in a string. 199 | func Strip(str string) string { 200 | return re.ReplaceAllString(str, "") 201 | } 202 | -------------------------------------------------------------------------------- /internal/completion/completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | // Completer is a function generating completions. 4 | // This is generally used so that a given completer function 5 | // (history, registers, etc) can be cached and reused by the engine. 6 | type Completer func() Values 7 | 8 | // Candidate represents a completion candidate. 9 | type Candidate struct { 10 | Value string // Value is the value of the completion as actually inserted in the line 11 | Display string // When display is not nil, this string is used to display the completion in the menu. 12 | Description string // A description to display next to the completion candidate. 13 | Style string // An arbitrary string of color/text effects to use when displaying the completion. 14 | Tag string // All completions with the same tag are grouped together and displayed under the tag heading. 15 | 16 | // A list of runes that are automatically trimmed when a space or a non-nil character is 17 | // inserted immediately after the completion. This is used for slash-autoremoval in path 18 | // completions, comma-separated completions, etc. 19 | noSpace SuffixMatcher 20 | 21 | displayLen int // Real length of the displayed candidate, that is not counting escaped sequences. 22 | descLen int 23 | } 24 | 25 | // Values is used internally to hold all completion candidates and their associated data. 26 | type Values struct { 27 | values RawValues 28 | Messages Messages 29 | NoSpace SuffixMatcher 30 | Usage string 31 | ListLong map[string]bool 32 | NoSort map[string]bool 33 | ListSep map[string]string 34 | Pad map[string]bool 35 | Escapes map[string]bool 36 | 37 | // Initially this will be set to the part of the current word 38 | // from the beginning of the word up to the position of the cursor. 39 | // It may be altered to give a prefix for all matches. 40 | PREFIX string 41 | 42 | // Initially this will be set to the part of the current word, 43 | // starting from the cursor position up to the end of the word. 44 | // It may be altered so that inserted completions don't overwrite 45 | // entirely any suffix when completing in the middle of a word. 46 | SUFFIX string 47 | } 48 | 49 | // AddRaw adds completion values in bulk. 50 | func AddRaw(values []Candidate) Values { 51 | return Values{ 52 | values: RawValues(values), 53 | ListLong: make(map[string]bool), 54 | NoSort: make(map[string]bool), 55 | ListSep: make(map[string]string), 56 | Pad: make(map[string]bool), 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/completion/display.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/reeflective/readline/internal/color" 10 | "github.com/reeflective/readline/internal/term" 11 | ) 12 | 13 | // Display prints the current completion list to the screen, 14 | // respecting the current display and completion settings. 15 | func Display(eng *Engine, maxRows int) { 16 | eng.usedY = 0 17 | 18 | defer fmt.Print(term.ClearScreenBelow) 19 | 20 | // The completion engine might be inactive but still having 21 | // a non-empty list of completions. This is on purpose, as 22 | // sometimes it's better to keep completions printed for a 23 | // little more time. The engine itself is responsible for 24 | // deleting those lists when it deems them useless. 25 | if eng.Matches() == 0 || eng.skipDisplay { 26 | fmt.Print(term.ClearLineAfter) 27 | return 28 | } 29 | 30 | // The final completions string to print. 31 | completions := term.ClearLineAfter 32 | 33 | for _, group := range eng.groups { 34 | completions += eng.renderCompletions(group) 35 | } 36 | 37 | // Crop the completions so that it fits within our terminal 38 | completions, eng.usedY = eng.cropCompletions(completions, maxRows) 39 | 40 | if completions != "" { 41 | fmt.Print(completions) 42 | } 43 | } 44 | 45 | // Coordinates returns the number of terminal rows used 46 | // when displaying the completions with Display(). 47 | func Coordinates(e *Engine) int { 48 | return e.usedY 49 | } 50 | 51 | // renderCompletions renders all completions in a given list (with aliases or not). 52 | // The descriptions list argument is optional. 53 | func (e *Engine) renderCompletions(grp *group) string { 54 | var builder strings.Builder 55 | 56 | if len(grp.rows) == 0 { 57 | return "" 58 | } 59 | 60 | if grp.tag != "" { 61 | tag := fmt.Sprintf("%s%s%s %s", color.Bold, color.FgYellow, grp.tag, color.Reset) 62 | builder.WriteString(tag + term.ClearLineAfter + term.NewlineReturn) 63 | } 64 | 65 | for rowIndex, row := range grp.rows { 66 | for columnIndex := range grp.columnsWidth { 67 | var value Candidate 68 | 69 | // If there are aliases, we might have no completions at the current 70 | // coordinates, so just print the corresponding padding and return. 71 | if len(row) > columnIndex { 72 | value = row[columnIndex] 73 | } 74 | 75 | // Apply all highlightings to the displayed value: 76 | // selection, prefixes, styles and other things, 77 | padding := grp.getPad(value, columnIndex, false) 78 | isSelected := rowIndex == grp.posY && columnIndex == grp.posX && grp.isCurrent 79 | display := e.highlightDisplay(grp, value, padding, columnIndex, isSelected) 80 | 81 | builder.WriteString(display) 82 | 83 | // Add description if no aliases, or if done with them. 84 | onLast := columnIndex == len(grp.columnsWidth)-1 85 | if grp.aliased && onLast && value.Description == "" { 86 | value = row[0] 87 | } 88 | 89 | if !grp.aliased || onLast { 90 | grp.maxDescAllowed = grp.setMaximumSizes(columnIndex) 91 | 92 | descPad := grp.getPad(value, columnIndex, true) 93 | desc := e.highlightDesc(grp, value, descPad, rowIndex, columnIndex, isSelected) 94 | builder.WriteString(desc) 95 | } 96 | } 97 | 98 | // We're done for this line. 99 | builder.WriteString(term.ClearLineAfter + term.NewlineReturn) 100 | } 101 | 102 | return builder.String() 103 | } 104 | 105 | func (e *Engine) highlightDisplay(grp *group, val Candidate, pad, col int, selected bool) (candidate string) { 106 | // An empty display value means padding. 107 | if val.Display == "" { 108 | return padSpace(pad) 109 | } 110 | 111 | style := color.Fmt(val.Style) 112 | candidate, padded := grp.trimDisplay(val, pad, col) 113 | 114 | if e.IsearchRegex != nil && e.isearchBuf.Len() > 0 && !selected { 115 | match := e.IsearchRegex.FindString(candidate) 116 | match = color.Fmt(color.Bg+"244") + match + color.Reset + style 117 | candidate = e.IsearchRegex.ReplaceAllLiteralString(candidate, match) 118 | } 119 | 120 | if selected { 121 | // If the comp is currently selected, overwrite any highlighting already applied. 122 | userStyle := color.UnquoteRC(e.config.GetString("completion-selection-style")) 123 | selectionHighlightStyle := color.Fmt(color.Bg+"255") + userStyle 124 | candidate = selectionHighlightStyle + candidate 125 | 126 | if grp.aliased { 127 | candidate += color.Reset 128 | } 129 | } else { 130 | // Highlight the prefix if any and configured for it. 131 | if e.config.GetBool("colored-completion-prefix") && e.prefix != "" { 132 | if prefixMatch, err := regexp.Compile("^" + e.prefix); err == nil { 133 | prefixColored := color.Bold + color.FgBlue + e.prefix + color.BoldReset + color.FgDefault + style 134 | candidate = prefixMatch.ReplaceAllString(candidate, prefixColored) 135 | } 136 | } 137 | 138 | candidate = style + candidate + color.Reset 139 | } 140 | 141 | return candidate + padded 142 | } 143 | 144 | func (e *Engine) highlightDesc(grp *group, val Candidate, pad, row, col int, selected bool) (desc string) { 145 | if val.Description == "" { 146 | return color.Reset 147 | } 148 | 149 | desc, padded := grp.trimDesc(val, pad) 150 | 151 | // If the next row has the same completions, replace the description with our hint. 152 | if len(grp.rows) > row+1 && grp.rows[row+1][0].Description == val.Description { 153 | desc = "|" 154 | } else if e.IsearchRegex != nil && e.isearchBuf.Len() > 0 && !selected { 155 | match := e.IsearchRegex.FindString(desc) 156 | match = color.Fmt(color.Bg+"244") + match + color.Reset + color.Dim 157 | desc = e.IsearchRegex.ReplaceAllLiteralString(desc, match) 158 | } 159 | 160 | // If the comp is currently selected, overwrite any highlighting already applied. 161 | // Replace all background reset escape sequences in it, to ensure correct display. 162 | if row == grp.posY && col == grp.posX && grp.isCurrent && !grp.aliased { 163 | userDescStyle := color.UnquoteRC(e.config.GetString("completion-selection-style")) 164 | selectionHighlightStyle := color.Fmt(color.Bg+"255") + userDescStyle 165 | desc = strings.ReplaceAll(desc, color.BgDefault, userDescStyle) 166 | desc = selectionHighlightStyle + desc 167 | } 168 | 169 | compDescStyle := color.UnquoteRC(e.config.GetString("completion-description-style")) 170 | 171 | return compDescStyle + desc + color.Reset + padded 172 | } 173 | 174 | // cropCompletions - When the user cycles through a completion list longer 175 | // than the console MaxTabCompleterRows value, we crop the completions string 176 | // so that "global" cycling (across all groups) is printed correctly. 177 | func (e *Engine) cropCompletions(comps string, maxRows int) (cropped string, usedY int) { 178 | // Get the current absolute candidate position 179 | absPos := e.getAbsPos() 180 | 181 | // Scan the completions for cutting them at newlines 182 | scanner := bufio.NewScanner(strings.NewReader(comps)) 183 | 184 | // If absPos < MaxTabCompleterRows, cut below MaxTabCompleterRows and return 185 | if absPos < maxRows-1 { 186 | return e.cutCompletionsBelow(scanner, maxRows) 187 | } 188 | 189 | // If absolute > MaxTabCompleterRows, cut above and below and return 190 | // -> This includes de facto when we tabCompletionReverse 191 | if absPos >= maxRows-1 { 192 | return e.cutCompletionsAboveBelow(scanner, maxRows, absPos) 193 | } 194 | 195 | return 196 | } 197 | 198 | func (e *Engine) cutCompletionsBelow(scanner *bufio.Scanner, maxRows int) (string, int) { 199 | var count int 200 | var cropped string 201 | 202 | for scanner.Scan() { 203 | line := scanner.Text() 204 | if count < maxRows-1 { 205 | cropped += line + term.NewlineReturn 206 | count++ 207 | } else { 208 | break 209 | } 210 | } 211 | 212 | cropped = strings.TrimSuffix(cropped, term.NewlineReturn) 213 | 214 | // Add hint for remaining completions, if any. 215 | _, used := e.completionCount() 216 | remain := used - count 217 | 218 | if remain <= 0 { 219 | return cropped, count - 1 220 | } 221 | 222 | cropped += fmt.Sprintf(term.NewlineReturn+color.Dim+color.FgYellow+" %d more completion rows... (scroll down to show)"+color.Reset, remain) 223 | 224 | return cropped, count 225 | } 226 | 227 | func (e *Engine) cutCompletionsAboveBelow(scanner *bufio.Scanner, maxRows, absPos int) (string, int) { 228 | cutAbove := absPos - maxRows + 1 229 | 230 | var cropped string 231 | var count int 232 | 233 | for scanner.Scan() { 234 | line := scanner.Text() 235 | 236 | if count <= cutAbove { 237 | count++ 238 | 239 | continue 240 | } 241 | 242 | if count > cutAbove && count <= absPos { 243 | cropped += line + term.NewlineReturn 244 | count++ 245 | } else { 246 | break 247 | } 248 | } 249 | 250 | cropped = strings.TrimSuffix(cropped, term.NewlineReturn) 251 | count -= cutAbove + 1 252 | 253 | // Add hint for remaining completions, if any. 254 | _, used := e.completionCount() 255 | remain := used - (maxRows + cutAbove) 256 | 257 | if remain <= 0 { 258 | return cropped, count - 1 259 | } 260 | 261 | cropped += fmt.Sprintf(term.NewlineReturn+color.Dim+color.FgYellow+" %d more completion rows... (scroll down to show)"+color.Reset, remain) 262 | 263 | return cropped, count 264 | } 265 | -------------------------------------------------------------------------------- /internal/completion/hint.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/reeflective/readline/internal/color" 7 | "github.com/reeflective/readline/internal/term" 8 | ) 9 | 10 | func (e *Engine) hintCompletions(comps Values) { 11 | hint := "" 12 | 13 | // First add the command/flag usage string if any, 14 | // and only if we don't have completions. 15 | if len(comps.values) == 0 || e.config.GetBool("usage-hint-always") { 16 | if comps.Usage != "" { 17 | hint += color.Dim + comps.Usage + color.Reset + term.NewlineReturn 18 | } 19 | } 20 | 21 | // Add application-specific messages. 22 | // There is full support for color in them, but in case those messages 23 | // don't include any, we tame the color a little bit first, like hints. 24 | messages := strings.Join(comps.Messages.Get(), term.NewlineReturn) 25 | messages = strings.TrimSuffix(messages, term.NewlineReturn) 26 | 27 | if messages != "" { 28 | hint = hint + color.Dim + messages 29 | } 30 | 31 | // If we don't have any completions, and no messages, let's say it. 32 | if e.Matches() == 0 && hint == color.Dim+term.NewlineReturn && !e.auto { 33 | hint = e.hintNoMatches() 34 | } 35 | 36 | hint = strings.TrimSuffix(hint, term.NewlineReturn) 37 | if hint == "" { 38 | return 39 | } 40 | 41 | // Add the hint to the shell. 42 | e.hint.Set(hint + color.Reset) 43 | } 44 | 45 | func (e *Engine) hintNoMatches() string { 46 | noMatches := color.Dim + "no matching" 47 | 48 | var groups []string 49 | 50 | for _, group := range e.groups { 51 | if group.tag == "" { 52 | continue 53 | } 54 | 55 | groups = append(groups, group.tag) 56 | } 57 | 58 | if len(groups) > 0 { 59 | groupsStr := strings.Join(groups, ", ") 60 | noMatches += "'" + groupsStr + "'" 61 | } 62 | 63 | return noMatches + " completions" 64 | } 65 | -------------------------------------------------------------------------------- /internal/completion/insert.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "unicode" 5 | 6 | "github.com/reeflective/readline/inputrc" 7 | "github.com/reeflective/readline/internal/core" 8 | "github.com/reeflective/readline/internal/keymap" 9 | ) 10 | 11 | // UpdateInserted should be called only once in between the two shell keymaps 12 | // (local/main) in the main readline loop, to either drop or confirm a virtually 13 | // inserted candidate. 14 | func UpdateInserted(eng *Engine) { 15 | // If the user currently has a completion selected, any change 16 | // in the input line will drop the current completion list, in 17 | // effect deactivating the completion engine. 18 | // This is so that when a user asks for the list of choices, but 19 | // then deletes or types something in the input line, the list 20 | // is still displayed to the user, otherwise it's removed. 21 | // This does not apply when autocomplete is on. 22 | choices := len(eng.selected.Value) != 0 23 | if !eng.auto { 24 | defer eng.ClearMenu(choices) 25 | } 26 | 27 | // If autocomplete is on, we also drop the list of generated 28 | // completions, because it will be recomputed shortly after. 29 | // Do the same when using incremental search, except if the 30 | // last key typed is an escape, in which case the user wants 31 | // to quit incremental search but keeping any selected comp. 32 | inserted := eng.mustRemoveInserted() 33 | cached := eng.keymap.Local() != keymap.Isearch && !eng.autoForce 34 | 35 | eng.Cancel(inserted, cached) 36 | 37 | if choices && eng.autoForce && len(eng.selected.Value) == 0 { 38 | eng.Reset() 39 | } 40 | } 41 | 42 | // TrimSuffix removes the last inserted completion's suffix if the required constraints 43 | // are satisfied (among which the index position, the suffix matching patterns, etc). 44 | func (e *Engine) TrimSuffix() { 45 | if e.line.Len() == 0 || e.cursor.Pos() == 0 || len(e.selected.Value) > 0 { 46 | return 47 | } 48 | 49 | // If our suffix matcher was registered at a different 50 | // place in our line, then it's an orphan. 51 | if e.sm.pos != e.cursor.Pos()-1 || e.sm.string == "" { 52 | e.sm = SuffixMatcher{} 53 | return 54 | } 55 | 56 | suf := (*e.line)[e.cursor.Pos()-1] 57 | keys := e.keys.Caller() 58 | key := keys[0] 59 | 60 | // Special case when completing paths: if the comp is ended 61 | // by a slash, only remove this slash if the inserted key is 62 | // one of the suffix matchers, otherwise keep it. 63 | if suf == '/' && key != inputrc.Space && notMatcher(key, e.sm.string) { 64 | return 65 | } 66 | 67 | // If the key is a space or matches the suffix matcher, cut the suffix. 68 | if e.sm.Matches(string(key)) || unicode.IsSpace(key) { 69 | e.cursor.Dec() 70 | e.line.CutRune(e.cursor.Pos()) 71 | } 72 | 73 | // But when the key is a space, we also drop the suffix matcher, 74 | // because the user is done with this precise completion (or group of). 75 | if unicode.IsSpace(key) { 76 | e.sm = SuffixMatcher{} 77 | } 78 | } 79 | 80 | // refreshLine - Either insert the only candidate in the real line 81 | // and drop the current completion list, prefix, keymaps, etc, or 82 | // swap the formerly selected candidate with the new one. 83 | func (e *Engine) refreshLine() { 84 | if e.noCompletions() { 85 | e.Cancel(true, true) 86 | return 87 | } 88 | 89 | if e.currentGroup() == nil { 90 | return 91 | } 92 | 93 | // Incremental search is a special case, because the user may 94 | // want to keep searching for another match, so we don't drop 95 | // the completion list and exit the incremental search mode. 96 | if e.hasUniqueCandidate() && e.keymap.Local() != keymap.Isearch { 97 | e.acceptCandidate() 98 | e.ResetForce() 99 | } else { 100 | e.insertCandidate() 101 | } 102 | } 103 | 104 | // acceptCandidate inserts the currently selected candidate into the real input line. 105 | func (e *Engine) acceptCandidate() { 106 | cur := e.currentGroup() 107 | if cur == nil { 108 | return 109 | } 110 | 111 | e.selected = cur.selected() 112 | 113 | // Prepare the completion candidate, remove the 114 | // prefix part and save its sufffixes for later. 115 | completion := e.prepareSuffix() 116 | e.inserted = []rune(completion) 117 | 118 | // Remove the line prefix and insert the candidate. 119 | e.cursor.Move(-1 * len(e.prefix)) 120 | e.line.Cut(e.cursor.Pos(), e.cursor.Pos()+len(e.prefix)) 121 | e.cursor.InsertAt(e.inserted...) 122 | 123 | // And forget about this inserted completion. 124 | e.inserted = make([]rune, 0) 125 | e.prefix = "" 126 | e.suffix = "" 127 | } 128 | 129 | // insertCandidate inserts a completion candidate into the virtual (completed) line. 130 | func (e *Engine) insertCandidate() { 131 | grp := e.currentGroup() 132 | if grp == nil { 133 | return 134 | } 135 | 136 | e.selected = grp.selected() 137 | 138 | if len(e.selected.Value) < len(e.prefix) { 139 | return 140 | } 141 | 142 | // Prepare the completion candidate, remove the 143 | // prefix part and save its sufffixes for later. 144 | completion := e.prepareSuffix() 145 | e.inserted = []rune(completion) 146 | 147 | // Copy the current (uncompleted) line/cursor. 148 | completed := core.Line(string(*e.line)) 149 | e.compLine = &completed 150 | 151 | e.compCursor = core.NewCursor(e.compLine) 152 | e.compCursor.Set(e.cursor.Pos()) 153 | 154 | // Remove the line prefix and insert the candidate. 155 | e.compCursor.Move(-1 * len(e.prefix)) 156 | e.compLine.Cut(e.compCursor.Pos(), e.compCursor.Pos()+len(e.prefix)) 157 | e.compCursor.InsertAt(e.inserted...) 158 | } 159 | 160 | // prepareSuffix caches any suffix matcher associated with the completion candidate 161 | // to be inserted/accepted into the input line, and trims it if required at this point. 162 | func (e *Engine) prepareSuffix() (comp string) { 163 | cur := e.currentGroup() 164 | if cur == nil { 165 | return 166 | } 167 | 168 | comp = e.selected.Value 169 | prefix := len(e.prefix) 170 | 171 | // When the completion has a size of 1, don't remove anything: 172 | // stacked flags, for example, will never be inserted otherwise. 173 | if len(comp) > 0 && len(comp[prefix:]) <= 1 { 174 | return 175 | } 176 | 177 | // If we are to even consider removing a suffix, we keep the suffix 178 | // matcher for later: whatever the decision we take here will be identical 179 | // to the one we take while removing suffix in "non-virtual comp" mode. 180 | e.sm = cur.noSpace 181 | e.sm.pos = e.cursor.Pos() + len(comp) - prefix - 1 182 | 183 | return comp 184 | } 185 | 186 | func (e *Engine) cancelCompletedLine() { 187 | // The completed line includes any currently selected 188 | // candidate, just overwrite it with the normal line. 189 | e.compLine.Set(*e.line...) 190 | e.compCursor.Set(e.cursor.Pos()) 191 | 192 | // And no virtual candidate anymore. 193 | e.selected = Candidate{} 194 | } 195 | 196 | func (e *Engine) mustRemoveInserted() bool { 197 | // All other completion modes do not want 198 | // the candidate to be removed from the line. 199 | if e.keymap.Local() != keymap.Isearch { 200 | return false 201 | } 202 | 203 | // Normally, we should have a key. 204 | key, empty := core.PeekKey(e.keys) 205 | if empty { 206 | return false 207 | } 208 | 209 | // Some keys trigger behavior different from the normal one: 210 | // Ex: if the key is a letter, the isearch buffer is updated 211 | // and the line-inserted match might be different, so remove. 212 | // If the key is 'Enter', the line will likely be accepted 213 | // with the currently inserted candidate. 214 | switch rune(key) { 215 | case inputrc.Esc, inputrc.Return: 216 | return false 217 | default: 218 | return true 219 | } 220 | } 221 | 222 | func notMatcher(key rune, matchers string) bool { 223 | for _, r := range matchers { 224 | if r == key { 225 | return false 226 | } 227 | } 228 | 229 | return true 230 | } 231 | -------------------------------------------------------------------------------- /internal/completion/isearch.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/reeflective/readline/internal/color" 7 | "github.com/reeflective/readline/internal/core" 8 | "github.com/reeflective/readline/internal/keymap" 9 | ) 10 | 11 | // IsearchStart starts incremental search (fuzzy-finding) 12 | // with values matching the isearch minibuffer as a regexp. 13 | func (e *Engine) IsearchStart(name string, autoinsert, replaceLine bool) { 14 | // Prepare all buffers and cursors. 15 | e.isearchInsert = autoinsert 16 | e.isearchReplaceLine = replaceLine 17 | 18 | e.isearchStartBuf = string(*e.line) 19 | e.isearchStartCursor = e.cursor.Pos() 20 | 21 | e.isearchBuf = new(core.Line) 22 | e.isearchCur = core.NewCursor(e.isearchBuf) 23 | 24 | // Prepare all keymaps and modes. 25 | e.auto = true 26 | e.keymap.SetLocal(keymap.Isearch) 27 | e.adaptIsearchInsertMode() 28 | 29 | // Hints 30 | e.isearchName = name 31 | e.hint.Set(color.Bold + color.FgCyan + e.isearchName + " (isearch): " + color.Reset + string(*e.isearchBuf)) 32 | } 33 | 34 | // IsearchStop exists the incremental search mode, 35 | // and drops the currently used regexp matcher. 36 | // If revertLine is true, the original line is restored. 37 | func (e *Engine) IsearchStop(revertLine bool) { 38 | // Reset all buffers and cursors. 39 | e.isearchBuf = nil 40 | e.IsearchRegex = nil 41 | e.isearchCur = nil 42 | 43 | // Reset the original line when needed. 44 | if e.isearchReplaceLine && revertLine { 45 | e.line.Set([]rune(e.isearchStartBuf)...) 46 | e.cursor.Set(e.isearchStartCursor) 47 | } 48 | 49 | e.isearchStartBuf = "" 50 | e.isearchStartCursor = 0 51 | e.isearchReplaceLine = false 52 | 53 | // And clear all related completion keymaps/modes. 54 | e.auto = false 55 | e.autoForce = false 56 | e.keymap.SetLocal("") 57 | e.resetIsearchInsertMode() 58 | } 59 | 60 | // GetBuffer returns the correct input line buffer (and its cursor/ 61 | // selection) depending on the context and active components: 62 | // - If in non/incremental-search mode, the minibuffer. 63 | // - If a completion is currently inserted, the completed line. 64 | // - If neither of the above, the normal input line. 65 | func (e *Engine) GetBuffer() (*core.Line, *core.Cursor, *core.Selection) { 66 | // Non/Incremental search buffer 67 | searching, _, _ := e.NonIncrementallySearching() 68 | 69 | if e.keymap.Local() == keymap.Isearch || searching { 70 | selection := core.NewSelection(e.isearchBuf, e.isearchCur) 71 | return e.isearchBuf, e.isearchCur, selection 72 | } 73 | 74 | // Completed line (with inserted candidate) 75 | if len(e.selected.Value) > 0 { 76 | return e.compLine, e.compCursor, e.selection 77 | } 78 | 79 | // Or completer inactive, normal input line. 80 | return e.line, e.cursor, e.selection 81 | } 82 | 83 | // UpdateIsearch recompiles the isearch buffer as a regex and 84 | // filters matching candidates in the available completions. 85 | func (e *Engine) UpdateIsearch() { 86 | searching, _, _ := e.NonIncrementallySearching() 87 | 88 | if e.keymap.Local() != keymap.Isearch && !searching { 89 | return 90 | } 91 | 92 | // If we have a virtually inserted candidate, it's because the 93 | // last action was a tab-complete selection: we don't need to 94 | // refresh the list of matches, as the minibuffer did not change, 95 | // and because it would make our currently selected comp to drop. 96 | if len(e.selected.Value) > 0 { 97 | return 98 | } 99 | 100 | // Update helpers depending on the search/minibuffer mode. 101 | if e.keymap.Local() == keymap.Isearch { 102 | e.updateIncrementalSearch() 103 | } else { 104 | e.updateNonIncrementalSearch() 105 | } 106 | } 107 | 108 | // NonIsearchStart starts a non-incremental, fake search mode: 109 | // it does not produce or tries to match against completions, 110 | // but uses a minibuffer similarly to incremental search mode. 111 | func (e *Engine) NonIsearchStart(name string, repeat, forward, substring bool) { 112 | if repeat { 113 | e.isearchBuf = new(core.Line) 114 | e.isearchBuf.Set([]rune(e.isearchLast)...) 115 | } else { 116 | e.isearchBuf = new(core.Line) 117 | } 118 | 119 | e.isearchCur = core.NewCursor(e.isearchBuf) 120 | e.isearchCur.Set(e.isearchBuf.Len()) 121 | 122 | e.isearchName = name 123 | e.isearchForward = forward 124 | e.isearchSubstring = substring 125 | 126 | e.keymap.NonIncrementalSearchStart() 127 | e.adaptIsearchInsertMode() 128 | } 129 | 130 | // NonIsearchStop exits the non-incremental search mode. 131 | func (e *Engine) NonIsearchStop() { 132 | e.isearchLast = string(*e.isearchBuf) 133 | e.isearchBuf = nil 134 | e.IsearchRegex = nil 135 | e.isearchCur = nil 136 | e.isearchForward = false 137 | e.isearchSubstring = false 138 | 139 | // Reset keymap and helpers 140 | e.keymap.NonIncrementalSearchStop() 141 | e.resetIsearchInsertMode() 142 | e.hint.Reset() 143 | } 144 | 145 | // NonIncrementallySearching returns true if the completion engine 146 | // is currently using a minibuffer for non-incremental search mode. 147 | func (e *Engine) NonIncrementallySearching() (searching, forward, substring bool) { 148 | searching = e.isearchCur != nil && e.keymap.Local() != keymap.Isearch 149 | forward = e.isearchForward 150 | substring = e.isearchSubstring 151 | 152 | return 153 | } 154 | 155 | func (e *Engine) updateIncrementalSearch() { 156 | var regexStr string 157 | if hasUpper(*e.isearchBuf) { 158 | regexStr = string(*e.isearchBuf) 159 | } else { 160 | regexStr = "(?i)" + string(*e.isearchBuf) 161 | } 162 | 163 | var err error 164 | 165 | e.IsearchRegex, err = regexp.Compile(regexStr) 166 | if err != nil { 167 | e.hint.Set(color.FgRed + "Failed to compile i-search regexp") 168 | } 169 | 170 | // Refresh completions with the current minibuffer as a filter. 171 | e.GenerateWith(e.cached) 172 | 173 | // And filter out the completions. 174 | for _, g := range e.groups { 175 | g.updateIsearch(e) 176 | } 177 | 178 | // Update the hint section. 179 | isearchHint := color.Bold + color.FgCyan + e.isearchName + " (inc-search)" 180 | 181 | if e.Matches() == 0 { 182 | isearchHint += color.Reset + color.Bold + color.FgRed + " (no matches)" 183 | } 184 | 185 | isearchHint += ": " + color.Reset + color.Bold + string(*e.isearchBuf) + color.Reset + "_" 186 | 187 | e.hint.Set(isearchHint) 188 | 189 | // And update the inserted candidate if autoinsert is enabled. 190 | if e.isearchInsert && e.Matches() > 0 && e.isearchBuf.Len() > 0 { 191 | // History incremental searches must replace the whole line. 192 | if e.isearchReplaceLine { 193 | e.prefix = "" 194 | e.line.Set() 195 | e.cursor.Set(0) 196 | } 197 | 198 | e.Select(1, 0) 199 | } else if e.isearchReplaceLine { 200 | // Else no matches, restore the original line. 201 | e.line.Set([]rune(e.isearchStartBuf)...) 202 | e.cursor.Set(e.isearchStartCursor) 203 | } 204 | } 205 | 206 | func (e *Engine) updateNonIncrementalSearch() { 207 | isearchHint := color.Bold + color.FgCyan + e.isearchName + 208 | " (non-inc-search): " + color.Reset + color.Bold + string(*e.isearchBuf) + color.Reset + "_" 209 | e.hint.Set(isearchHint) 210 | } 211 | 212 | func (e *Engine) adaptIsearchInsertMode() { 213 | e.isearchModeExit = e.keymap.Main() 214 | 215 | if !e.keymap.IsEmacs() && e.keymap.Main() != keymap.ViInsert { 216 | e.keymap.SetMain(keymap.ViInsert) 217 | } 218 | } 219 | 220 | func (e *Engine) resetIsearchInsertMode() { 221 | if e.isearchModeExit == "" { 222 | return 223 | } 224 | 225 | if e.keymap.Main() != e.isearchModeExit { 226 | e.keymap.SetMain(string(e.isearchModeExit)) 227 | e.isearchModeExit = "" 228 | } 229 | 230 | if e.keymap.Main() == keymap.ViCommand { 231 | e.cursor.CheckCommand() 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /internal/completion/message.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "regexp" 5 | "sort" 6 | ) 7 | 8 | // Messages is a list of messages to be displayed 9 | // below the input line, above completions. It is 10 | // used to show usage and/or error status hints. 11 | type Messages struct { 12 | messages map[string]bool 13 | } 14 | 15 | func (m *Messages) init() { 16 | if m.messages == nil { 17 | m.messages = make(map[string]bool) 18 | } 19 | } 20 | 21 | // IsEmpty returns true if there are no messages to display. 22 | func (m Messages) IsEmpty() bool { 23 | // TODO replacement for Action.skipCache - does this need to consider suppressed messages or is this fine? 24 | return len(m.messages) == 0 25 | } 26 | 27 | // Add adds a message to the list of messages. 28 | func (m *Messages) Add(s string) { 29 | m.init() 30 | m.messages[s] = true 31 | } 32 | 33 | // Get returns the list of messages to display. 34 | func (m Messages) Get() []string { 35 | messages := make([]string, 0) 36 | for message := range m.messages { 37 | messages = append(messages, message) 38 | } 39 | 40 | sort.Strings(messages) 41 | 42 | return messages 43 | } 44 | 45 | // Suppress removes messages matching the given regular expressions from the list of messages. 46 | func (m *Messages) Suppress(expr ...string) error { 47 | m.init() 48 | 49 | for _, e := range expr { 50 | char, err := regexp.Compile(e) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | for key := range m.messages { 56 | if char.MatchString(key) { 57 | delete(m.messages, key) 58 | } 59 | } 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // Merge merges the given messages into the current list of messages. 66 | func (m *Messages) Merge(other Messages) { 67 | if other.messages == nil { 68 | return 69 | } 70 | 71 | for key := range other.messages { 72 | m.Add(key) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/completion/suffix.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | // SuffixMatcher is a type managing suffixes for a given list of completions. 9 | type SuffixMatcher struct { 10 | string 11 | pos int // Used to know if the saved suffix matcher is deprecated 12 | } 13 | 14 | // Add adds new suffixes to the matcher. 15 | func (sm *SuffixMatcher) Add(suffixes ...rune) { 16 | if strings.Contains(sm.string, "*") || strings.Contains(string(suffixes), "*") { 17 | sm.string = "*" 18 | 19 | return 20 | } 21 | 22 | unique := []rune(sm.string) 23 | 24 | for _, r := range suffixes { 25 | if !strings.Contains(sm.string, string(r)) { 26 | unique = append(unique, r) 27 | } 28 | } 29 | 30 | sort.Sort(byRune(unique)) 31 | sm.string = string(unique) 32 | } 33 | 34 | // Merge merges two suffix matchers. 35 | func (sm *SuffixMatcher) Merge(other SuffixMatcher) { 36 | for _, r := range other.string { 37 | sm.Add(r) 38 | } 39 | } 40 | 41 | // Matches returns true if the given string matches one of the suffixes. 42 | func (sm SuffixMatcher) Matches(s string) bool { 43 | for _, r := range sm.string { 44 | if r == '*' || strings.HasSuffix(s, string(r)) { 45 | return true 46 | } 47 | } 48 | 49 | return false 50 | } 51 | 52 | type byRune []rune 53 | 54 | func (r byRune) Len() int { return len(r) } 55 | func (r byRune) Swap(i, j int) { r[i], r[j] = r[j], r[i] } 56 | func (r byRune) Less(i, j int) bool { return r[i] < r[j] } 57 | -------------------------------------------------------------------------------- /internal/completion/syntax.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "github.com/reeflective/readline/internal/core" 5 | "github.com/reeflective/readline/internal/strutil" 6 | ) 7 | 8 | // CompleteSyntax updates the line with either user-defined syntax completers, or with the builtin ones. 9 | func (e *Engine) CompleteSyntax(completer func([]rune, int) ([]rune, int)) { 10 | if completer == nil { 11 | return 12 | } 13 | 14 | line := []rune(*e.line) 15 | pos := e.cursor.Pos() - 1 16 | 17 | newLine, newPos := completer(line, pos) 18 | if string(newLine) == string(line) { 19 | return 20 | } 21 | 22 | newPos++ 23 | 24 | e.line.Set(newLine...) 25 | e.cursor.Set(newPos) 26 | } 27 | 28 | // AutopairInsertOrJump checks if the character to be inserted in the line is a pair character. 29 | // If the character is an opening one, its inserted along with its closing equivalent. 30 | // If it's a closing one and the next character in line is the same, the cursor jumps over it. 31 | func AutopairInsertOrJump(key rune, line *core.Line, cur *core.Cursor) (skipInsert bool) { 32 | matcher, closer := strutil.SurroundType(key) 33 | 34 | if !matcher { 35 | return 36 | } 37 | 38 | switch { 39 | case closer && cur.Char() == key: 40 | skipInsert = true 41 | 42 | cur.Inc() 43 | case closer && key != '\'' && key != '"': 44 | return 45 | default: 46 | _, closeChar := strutil.MatchSurround(key) 47 | line.Insert(cur.Pos(), closeChar) 48 | } 49 | 50 | return 51 | } 52 | 53 | // AutopairDelete checks if the character under the cursor is an opening pair 54 | // character which is immediately followed by its closing equivalent. If yes, 55 | // the closing character is removed. 56 | func AutopairDelete(line *core.Line, cur *core.Cursor) { 57 | if cur.Pos() == 0 { 58 | return 59 | } 60 | 61 | toDelete := (*line)[cur.Pos()-1] 62 | isPair, _ := strutil.SurroundType(toDelete) 63 | matcher := strutil.IsSurround(toDelete, cur.Char()) 64 | 65 | // Cut the (closing) rune under the cursor. 66 | if isPair && matcher { 67 | line.CutRune(cur.Pos()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/completion/values.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // RawValues is a list of completion candidates. 8 | type RawValues []Candidate 9 | 10 | // Filter filters values. 11 | func (c RawValues) Filter(values ...string) RawValues { 12 | toremove := make(map[string]bool) 13 | for _, v := range values { 14 | toremove[v] = true 15 | } 16 | 17 | filtered := make([]Candidate, 0) 18 | 19 | for _, rawValue := range c { 20 | if _, ok := toremove[rawValue.Value]; !ok { 21 | filtered = append(filtered, rawValue) 22 | } 23 | } 24 | 25 | return filtered 26 | } 27 | 28 | // Merge merges a set of values with the current ones, 29 | // include usage/message strings, meta settings, etc. 30 | func (c *Values) Merge(other Values) { 31 | if other.Usage != "" { 32 | c.Usage = other.Usage 33 | } 34 | 35 | c.NoSpace.Merge(other.NoSpace) 36 | c.Messages.Merge(other.Messages) 37 | 38 | for tag := range other.ListLong { 39 | if _, found := c.ListLong[tag]; !found { 40 | c.ListLong[tag] = true 41 | } 42 | } 43 | } 44 | 45 | // EachTag iterates over each tag and runs a function for each group. 46 | func (c RawValues) EachTag(tagF func(tag string, values RawValues)) { 47 | tags := make([]string, 0) 48 | tagGroups := make(map[string]RawValues) 49 | 50 | for _, val := range c { 51 | if _, exists := tagGroups[val.Tag]; !exists { 52 | tagGroups[val.Tag] = make(RawValues, 0) 53 | 54 | tags = append(tags, val.Tag) 55 | } 56 | 57 | tagGroups[val.Tag] = append(tagGroups[val.Tag], val) 58 | } 59 | 60 | for _, tag := range tags { 61 | tagF(tag, tagGroups[tag]) 62 | } 63 | } 64 | 65 | // FilterPrefix filters values with given prefix. 66 | // If matchCase is false, the filtering is made case-insensitive. 67 | // This function ensures that all spaces are correctly. 68 | func (c RawValues) FilterPrefix(prefix string, matchCase bool) RawValues { 69 | if prefix == "" { 70 | return c 71 | } 72 | 73 | filtered := make(RawValues, 0) 74 | 75 | if !matchCase { 76 | prefix = strings.ToLower(prefix) 77 | } 78 | 79 | for _, raw := range c { 80 | val := raw.Value 81 | 82 | if !matchCase { 83 | val = strings.ToLower(val) 84 | } 85 | 86 | if strings.HasPrefix(val, prefix) { 87 | filtered = append(filtered, raw) 88 | } 89 | } 90 | 91 | return filtered 92 | } 93 | 94 | func (c RawValues) Len() int { return len(c) } 95 | 96 | func (c RawValues) Swap(i, j int) { c[i], c[j] = c[j], c[i] } 97 | 98 | func (c RawValues) Less(i, j int) bool { 99 | return strings.ToLower(c[i].Value) < strings.ToLower(c[j].Value) 100 | } 101 | -------------------------------------------------------------------------------- /internal/core/api_windows.go: -------------------------------------------------------------------------------- 1 | // Code taken from github.com/chzyer/readline. 2 | 3 | //go:build windows 4 | // +build windows 5 | 6 | package core 7 | 8 | import ( 9 | "reflect" 10 | "syscall" 11 | "unsafe" 12 | ) 13 | 14 | var ( 15 | kernel = NewKernel() 16 | stdout = uintptr(syscall.Stdout) 17 | stdin = uintptr(syscall.Stdin) 18 | ) 19 | 20 | // Kernel stores the Windows kernel32.dll functions required for terminal control. 21 | type Kernel struct { 22 | SetConsoleCursorPosition, 23 | SetConsoleTextAttribute, 24 | FillConsoleOutputCharacterW, 25 | FillConsoleOutputAttribute, 26 | ReadConsoleInputW, 27 | GetConsoleScreenBufferInfo, 28 | GetConsoleCursorInfo, 29 | GetStdHandle CallFunc 30 | } 31 | 32 | type ( 33 | short int16 34 | word uint16 35 | dword uint32 36 | wchar uint16 37 | ) 38 | 39 | type _COORD struct { 40 | x short 41 | y short 42 | } 43 | 44 | func (c *_COORD) ptr() uintptr { 45 | return uintptr(*(*int32)(unsafe.Pointer(c))) 46 | } 47 | 48 | const ( 49 | EVENT_KEY = 0x0001 // Event for key press/release 50 | EVENT_MOUSE = 0x0002 // Event for mouse action 51 | EVENT_WINDOW_BUFFER_SIZE = 0x0004 // Event for window resize 52 | EVENT_MENU = 0x0008 // Event for the menu keys 53 | EVENT_FOCUS = 0x0010 // Event for focus change 54 | ) 55 | 56 | type _KEY_EVENT_RECORD struct { 57 | bKeyDown int32 58 | wRepeatCount word 59 | wVirtualKeyCode word 60 | wVirtualScanCode word 61 | unicodeChar wchar 62 | dwControlKeyState dword 63 | } 64 | 65 | // KEY_EVENT_RECORD KeyEvent; 66 | // MOUSE_EVENT_RECORD MouseEvent; 67 | // WINDOW_BUFFER_SIZE_RECORD WindowBufferSizeEvent; 68 | // MENU_EVENT_RECORD MenuEvent; 69 | // FOCUS_EVENT_RECORD FocusEvent; 70 | type _INPUT_RECORD struct { 71 | EventType word 72 | Padding uint16 73 | Event [16]byte 74 | } 75 | 76 | type _CONSOLE_SCREEN_BUFFER_INFO struct { 77 | dwSize _COORD 78 | dwCursorPosition _COORD 79 | wAttributes word 80 | srWindow _SMALL_RECT 81 | dwMaximumWindowSize _COORD 82 | } 83 | 84 | type _SMALL_RECT struct { 85 | left short 86 | top short 87 | right short 88 | bottom short 89 | } 90 | 91 | type _CONSOLE_CURSOR_INFO struct { 92 | dwSize dword 93 | bVisible bool 94 | } 95 | 96 | type _FOCUS_EVENT_RECORD struct { 97 | bSetFocus bool 98 | } 99 | 100 | // CallFunc is a function that calls a Windows API function. 101 | type CallFunc func(u ...uintptr) error 102 | 103 | // NewKernel returns a new Kernel with all the required Windows API functions. 104 | func NewKernel() *Kernel { 105 | k := &Kernel{} 106 | kernel32 := syscall.NewLazyDLL("kernel32.dll") 107 | v := reflect.ValueOf(k).Elem() 108 | t := v.Type() 109 | for i := 0; i < t.NumField(); i++ { 110 | name := t.Field(i).Name 111 | f := kernel32.NewProc(name) 112 | v.Field(i).Set(reflect.ValueOf(k.Wrap(f))) 113 | } 114 | return k 115 | } 116 | 117 | // Wrap wraps a Windows API function into a callback function. 118 | func (k *Kernel) Wrap(p *syscall.LazyProc) CallFunc { 119 | return func(args ...uintptr) error { 120 | var r0 uintptr 121 | var e1 syscall.Errno 122 | size := uintptr(len(args)) 123 | if len(args) <= 3 { 124 | buf := make([]uintptr, 3) 125 | copy(buf, args) 126 | r0, _, e1 = syscall.Syscall(p.Addr(), size, 127 | buf[0], buf[1], buf[2]) 128 | } else { 129 | buf := make([]uintptr, 6) 130 | copy(buf, args) 131 | r0, _, e1 = syscall.Syscall6(p.Addr(), size, 132 | buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], 133 | ) 134 | } 135 | 136 | if int(r0) == 0 { 137 | if e1 != 0 { 138 | return error(e1) 139 | } 140 | return syscall.EINVAL 141 | } 142 | return nil 143 | } 144 | } 145 | 146 | // getConsoleScreenBufferInfo returns the current screen buffer information on Windows. 147 | func getConsoleScreenBufferInfo() (*_CONSOLE_SCREEN_BUFFER_INFO, error) { 148 | t := new(_CONSOLE_SCREEN_BUFFER_INFO) 149 | err := kernel.GetConsoleScreenBufferInfo( 150 | stdout, 151 | uintptr(unsafe.Pointer(t)), 152 | ) 153 | return t, err 154 | } 155 | 156 | // getConsoleCursorInfo returns the current cursor information on Windows. 157 | func getConsoleCursorInfo() (*_CONSOLE_CURSOR_INFO, error) { 158 | t := new(_CONSOLE_CURSOR_INFO) 159 | err := kernel.GetConsoleCursorInfo(stdout, uintptr(unsafe.Pointer(t))) 160 | return t, err 161 | } 162 | 163 | // setConsoleCursorInfo sets the cursor position on Windows. 164 | func setConsoleCursorPosition(c *_COORD) error { 165 | return kernel.SetConsoleCursorPosition(stdout, c.ptr()) 166 | } 167 | 168 | // GetCursorPos returns the current cursor position on Windows. 169 | func (k *Keys) GetCursorPos() (x, y int) { 170 | t := new(_CONSOLE_SCREEN_BUFFER_INFO) 171 | kernel.GetConsoleScreenBufferInfo( 172 | stdout, 173 | uintptr(unsafe.Pointer(t)), 174 | ) 175 | 176 | x = int(t.dwCursorPosition.x) + 1 177 | y = int(t.dwCursorPosition.y) 178 | 179 | return 180 | } 181 | -------------------------------------------------------------------------------- /internal/core/iterations.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/reeflective/readline/internal/color" 9 | ) 10 | 11 | // Iterations manages iterations for commands. 12 | type Iterations struct { 13 | times string // Stores iteration value 14 | active bool // Are we currently setting the iterations. 15 | pending bool // Has the last command been an iteration one (vi-pending style) 16 | } 17 | 18 | // Add accepts a string to be converted as an integer representing 19 | // the number of times some action should be performed. 20 | // The times parameter can also be a negative sign, in which case 21 | // the iterations value will be negative until those are reset. 22 | func (i *Iterations) Add(times string) { 23 | if times == "" { 24 | return 25 | } 26 | 27 | // Never accept non-digit values. 28 | if _, err := strconv.Atoi(times); err != nil && times != "-" { 29 | return 30 | } 31 | 32 | i.active = true 33 | i.pending = true 34 | 35 | switch { 36 | case times == "-": 37 | i.times = times + i.times 38 | case strings.HasPrefix(times, "-"): 39 | i.times = "-" + i.times + strings.TrimPrefix(times, "-") 40 | default: 41 | i.times += times 42 | } 43 | } 44 | 45 | // Get returns the number of iterations (possibly 46 | // negative), and resets the iterations to 1. 47 | func (i *Iterations) Get() int { 48 | times, err := strconv.Atoi(i.times) 49 | 50 | // Any invalid value is still one time. 51 | if err != nil && strings.HasPrefix(i.times, "-") { 52 | times = -1 53 | } else if err != nil && times == 0 { 54 | times = 1 55 | } else if times == 0 && strings.HasPrefix(i.times, "-") { 56 | times = -1 57 | } 58 | 59 | // At least one iteration 60 | if times == 0 { 61 | times++ 62 | } 63 | 64 | i.times = "" 65 | 66 | return times 67 | } 68 | 69 | // IsSet returns true if an iteration/numeric argument is active. 70 | func (i *Iterations) IsSet() bool { 71 | return i.active 72 | } 73 | 74 | // IsPending returns true if the very last command executed was an 75 | // iteration one. This is only meant for the main readline loop/run. 76 | func (i *Iterations) IsPending() bool { 77 | return i.pending 78 | } 79 | 80 | // Reset resets the iterations (drops them). 81 | func (i *Iterations) Reset() { 82 | i.times = "" 83 | i.active = false 84 | i.pending = false 85 | } 86 | 87 | // ResetPostRunIterations resets the iterations if the last command didn't set them. 88 | // If the reset operated on active iterations, this function returns true. 89 | func ResetPostRunIterations(iter *Iterations) (hint string) { 90 | if iter.pending { 91 | hint = color.Dim + fmt.Sprintf("(arg: %s)", iter.times) 92 | } 93 | 94 | if iter.pending { 95 | iter.pending = false 96 | 97 | return 98 | } 99 | 100 | iter.active = false 101 | 102 | return 103 | } 104 | -------------------------------------------------------------------------------- /internal/core/iterations_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/reeflective/readline/internal/color" 8 | ) 9 | 10 | func TestIterations_Add(t *testing.T) { 11 | type fields struct { 12 | times string 13 | active bool 14 | pending bool 15 | } 16 | type args struct { 17 | times string 18 | } 19 | tests := []struct { 20 | name string 21 | fields fields 22 | args args 23 | wantTimes string 24 | wantActive bool 25 | wantPending bool 26 | }{ 27 | { 28 | name: "Add an empty string as iterations", 29 | fields: fields{}, 30 | args: args{times: ""}, 31 | }, 32 | { 33 | name: "Add a minus sign as iterations", 34 | fields: fields{}, 35 | args: args{times: "-"}, 36 | wantTimes: "-", 37 | wantActive: true, 38 | wantPending: true, 39 | }, 40 | { 41 | name: "Add a string of zeros as iterations", 42 | fields: fields{}, 43 | args: args{times: "000"}, 44 | wantTimes: "000", 45 | wantActive: true, 46 | wantPending: true, 47 | }, 48 | { 49 | name: "Add a minus sign to non-0 iterations", 50 | fields: fields{times: "10"}, 51 | args: args{times: "-"}, 52 | wantTimes: "-10", 53 | wantActive: true, 54 | wantPending: true, 55 | }, 56 | { 57 | name: "Add a negative number to iterations", 58 | fields: fields{times: "10"}, 59 | args: args{times: "-1"}, 60 | wantTimes: "-101", 61 | wantActive: true, 62 | wantPending: true, 63 | }, 64 | { 65 | name: "Add a string of letters to iterations (invalid)", 66 | fields: fields{times: "10"}, 67 | args: args{times: "abc"}, 68 | wantTimes: "10", 69 | }, 70 | } 71 | 72 | for _, test := range tests { 73 | t.Run(test.name, func(t *testing.T) { 74 | iter := &Iterations{ 75 | times: test.fields.times, 76 | active: test.fields.active, 77 | pending: test.fields.pending, 78 | } 79 | iter.Add(test.args.times) 80 | 81 | if wantTimes := test.wantTimes; iter.times != wantTimes { 82 | t.Errorf("Iterations.Add() = %v, want %v", iter.times, wantTimes) 83 | } 84 | 85 | if wantActive := test.wantActive; iter.active != wantActive { 86 | t.Errorf("Iterations.Add() = %v, want %v", iter.active, wantActive) 87 | } 88 | 89 | if wantPending := test.wantPending; iter.pending != wantPending { 90 | t.Errorf("Iterations.Add() = %v, want %v", iter.pending, wantPending) 91 | } 92 | }) 93 | } 94 | } 95 | 96 | func TestIterations_Get(t *testing.T) { 97 | type fields struct { 98 | times string 99 | active bool 100 | pending bool 101 | } 102 | tests := []struct { 103 | name string 104 | fields fields 105 | want int 106 | }{ 107 | { 108 | name: "Empty string is one time", 109 | fields: fields{}, 110 | want: 1, 111 | }, 112 | { 113 | name: "Minus sign alone (-1)", 114 | fields: fields{times: "-"}, 115 | want: -1, 116 | }, 117 | { 118 | name: "String of zeros (000) (1)", 119 | fields: fields{times: "000"}, 120 | want: 1, 121 | }, 122 | { 123 | name: "String of negative zeros (-000) (-1)", 124 | fields: fields{times: "-000"}, 125 | want: -1, 126 | }, 127 | { 128 | name: "Positive number (10) (10)", 129 | fields: fields{times: "10"}, 130 | want: 10, 131 | }, 132 | { 133 | name: "Negative number (-10) (-10)", 134 | fields: fields{times: "-10"}, 135 | want: -10, 136 | }, 137 | { 138 | name: "Letters, invalid (1)", 139 | fields: fields{times: "abc"}, 140 | want: 1, 141 | }, 142 | } 143 | 144 | for _, test := range tests { 145 | t.Run(test.name, func(t *testing.T) { 146 | iter := &Iterations{ 147 | times: test.fields.times, 148 | active: test.fields.active, 149 | pending: test.fields.pending, 150 | } 151 | 152 | if got := iter.Get(); got != test.want { 153 | t.Errorf("Iterations.Get() = %v, want %v", got, test.want) 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func TestIterations_Reset(t *testing.T) { 160 | type fields struct { 161 | times string 162 | active bool 163 | pending bool 164 | } 165 | tests := []struct { 166 | name string 167 | fields fields 168 | }{ 169 | { 170 | name: "Empty string is one time", 171 | fields: fields{}, 172 | }, 173 | { 174 | name: "Minus sign alone (-1)", 175 | fields: fields{times: "-"}, 176 | }, 177 | { 178 | name: "String of zeros (000) (1)", 179 | fields: fields{times: "000"}, 180 | }, 181 | { 182 | name: "String of negative zeros (-000) (-1)", 183 | fields: fields{times: "-000"}, 184 | }, 185 | { 186 | name: "Positive number (10) (10)", 187 | fields: fields{times: "10"}, 188 | }, 189 | { 190 | name: "Negative number (-10) (-10)", 191 | fields: fields{times: "-10"}, 192 | }, 193 | { 194 | name: "Letters, invalid (1)", 195 | fields: fields{times: "abc"}, 196 | }, 197 | } 198 | 199 | for _, test := range tests { 200 | t.Run(test.name, func(t *testing.T) { 201 | iter := &Iterations{ 202 | times: test.fields.times, 203 | active: test.fields.active, 204 | pending: test.fields.pending, 205 | } 206 | iter.Reset() 207 | 208 | if got := iter.Get(); got != 1 { 209 | t.Errorf("Iterations.Reset() = %v, want %v", got, 1) 210 | } 211 | }) 212 | } 213 | } 214 | 215 | func TestResetPostRunIterations(t *testing.T) { 216 | type args struct { 217 | iter *Iterations 218 | } 219 | type fields struct { 220 | times string 221 | pending bool 222 | } 223 | tests := []struct { 224 | name string 225 | fields fields 226 | args args 227 | wantHint string 228 | }{ 229 | { 230 | name: "Minus sign alone (-1) (not pending)", 231 | fields: fields{times: "-"}, 232 | }, 233 | { 234 | name: "String of zeros (000) (1) (pending)", 235 | fields: fields{times: "000", pending: true}, 236 | wantHint: color.Dim + fmt.Sprintf("(arg: %s)", "000"), 237 | }, 238 | { 239 | name: "String of negative zeros (-000) (-1) (not pending)", 240 | fields: fields{times: "-000"}, 241 | }, 242 | { 243 | name: "Positive number (10) (10) (pending)", 244 | fields: fields{times: "10", pending: true}, 245 | wantHint: color.Dim + fmt.Sprintf("(arg: %s)", "10"), 246 | }, 247 | { 248 | name: "Negative number (-10) (-10) (not pending)", 249 | fields: fields{times: "-10"}, 250 | }, 251 | { 252 | name: "Letters, invalid (1) (pending)", 253 | fields: fields{times: "abc", pending: true}, 254 | wantHint: color.Dim + fmt.Sprintf("(arg: %s)", ""), 255 | }, 256 | } 257 | 258 | for _, test := range tests { 259 | t.Run(test.name, func(t *testing.T) { 260 | // Set up the iterations 261 | test.args.iter = &Iterations{ 262 | pending: test.fields.pending, 263 | } 264 | 265 | // Call the iterations add method 266 | test.args.iter.Add(test.fields.times) 267 | test.args.iter.pending = test.fields.pending 268 | 269 | if gotHint := ResetPostRunIterations(test.args.iter); gotHint != test.wantHint { 270 | t.Errorf("ResetPostRunIterations() = %v, want %v", gotHint, test.wantHint) 271 | } 272 | }) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /internal/core/keys_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package core 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strconv" 11 | ) 12 | 13 | // GetCursorPos returns the current cursor position in the terminal. 14 | // It is safe to call this function even if the shell is reading input. 15 | func (k *Keys) GetCursorPos() (x, y int) { 16 | disable := func() (int, int) { 17 | os.Stderr.WriteString("\r\ngetCursorPos() not supported by terminal emulator, disabling....\r\n") 18 | return -1, -1 19 | } 20 | 21 | var cursor []byte 22 | var match [][]string 23 | 24 | // Echo the query and wait for the main key 25 | // reading routine to send us the response back. 26 | fmt.Print("\x1b[6n") 27 | 28 | // In order not to get stuck with an input that might be user-one 29 | // (like when the user typed before the shell is fully started, and yet not having 30 | // queried cursor yet), we keep reading from stdin until we find the cursor response. 31 | // Everything else is passed back as user input. 32 | for { 33 | switch { 34 | case k.waiting, k.reading: 35 | cursor = <-k.cursor 36 | default: 37 | buf := make([]byte, keyScanBufSize) 38 | 39 | read, err := os.Stdin.Read(buf) 40 | if err != nil { 41 | return disable() 42 | } 43 | 44 | cursor = buf[:read] 45 | } 46 | 47 | // We have read (or have been passed) something. 48 | if len(cursor) == 0 { 49 | return disable() 50 | } 51 | 52 | // Attempt to locate cursor response in it. 53 | match = rxRcvCursorPos.FindAllStringSubmatch(string(cursor), 1) 54 | 55 | // If there is something but not cursor answer, its user input. 56 | if len(match) == 0 && len(cursor) > 0 { 57 | k.mutex.RLock() 58 | k.buf = append(k.buf, cursor...) 59 | k.mutex.RUnlock() 60 | 61 | continue 62 | } 63 | 64 | // And if empty, then we should abort. 65 | if len(match) == 0 { 66 | return disable() 67 | } 68 | 69 | break 70 | } 71 | 72 | // We know that we have a cursor answer, process it. 73 | y, err := strconv.Atoi(match[0][1]) 74 | if err != nil { 75 | return disable() 76 | } 77 | 78 | x, err = strconv.Atoi(match[0][2]) 79 | if err != nil { 80 | return disable() 81 | } 82 | 83 | return x, y 84 | } 85 | 86 | func (k *Keys) readInputFiltered() (keys []byte, err error) { 87 | // Start reading from os.Stdin in the background. 88 | // We will either read keys from user, or an EOF 89 | // send by ourselves, because we pause reading. 90 | buf := make([]byte, keyScanBufSize) 91 | 92 | read, err := Stdin.Read(buf) 93 | if err != nil && errors.Is(err, io.EOF) { 94 | return 95 | } 96 | 97 | // Always attempt to extract cursor position info. 98 | // If found, strip it and keep the remaining keys. 99 | cursor, keys := k.extractCursorPos(buf[:read]) 100 | 101 | if len(cursor) > 0 { 102 | k.cursor <- cursor 103 | } 104 | 105 | return keys, nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/core/keys_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package core 5 | 6 | import ( 7 | "errors" 8 | "io" 9 | "unsafe" 10 | 11 | "github.com/reeflective/readline/inputrc" 12 | ) 13 | 14 | // Windows-specific special key codes. 15 | const ( 16 | VK_CANCEL = 0x03 17 | VK_BACK = 0x08 18 | VK_TAB = 0x09 19 | VK_RETURN = 0x0D 20 | VK_SHIFT = 0x10 21 | VK_CONTROL = 0x11 22 | VK_MENU = 0x12 23 | VK_ESCAPE = 0x1B 24 | VK_LEFT = 0x25 25 | VK_UP = 0x26 26 | VK_RIGHT = 0x27 27 | VK_DOWN = 0x28 28 | VK_DELETE = 0x2E 29 | VK_LSHIFT = 0xA0 30 | VK_RSHIFT = 0xA1 31 | VK_LCONTROL = 0xA2 32 | VK_RCONTROL = 0xA3 33 | VK_SNAPSHOT = 0x2C 34 | VK_INSERT = 0x2D 35 | VK_HOME = 0x24 36 | VK_END = 0x23 37 | VK_PRIOR = 0x21 38 | VK_NEXT = 0x22 39 | ) 40 | 41 | // Use an undefined Virtual Key sequence to pass 42 | // Windows terminal resize events from the reader. 43 | const ( 44 | WINDOWS_RESIZE = 0x07 45 | ) 46 | 47 | const ( 48 | charTab = 9 49 | charCtrlH = 8 50 | charBackspace = 127 51 | ) 52 | 53 | func init() { 54 | Stdin = newRawReader() 55 | } 56 | 57 | // GetTerminalResize sends booleans over a channel to notify resize events on Windows. 58 | // This functions uses the keys reader because on Windows, resize events are sent through 59 | // stdin, not with syscalls like unix's syscall.SIGWINCH. 60 | func GetTerminalResize(keys *Keys) <-chan bool { 61 | keys.resize = make(chan bool, 1) 62 | 63 | return keys.resize 64 | } 65 | 66 | // readInputFiltered on Windows needs to check for terminal resize events. 67 | func (k *Keys) readInputFiltered() (keys []byte, err error) { 68 | for { 69 | // Start reading from os.Stdin in the background. 70 | // We will either read keys from user, or an EOF 71 | // send by ourselves, because we pause reading. 72 | buf := make([]byte, keyScanBufSize) 73 | 74 | read, err := Stdin.Read(buf) 75 | if err != nil && errors.Is(err, io.EOF) { 76 | return keys, err 77 | } 78 | 79 | input := buf[:read] 80 | 81 | // On Windows, windows resize events are sent through stdin, 82 | // so if one is detected, send it back to the display engine. 83 | if len(input) == 1 && input[0] == WINDOWS_RESIZE { 84 | k.resize <- true 85 | continue 86 | } 87 | 88 | // Always attempt to extract cursor position info. 89 | // If found, strip it and keep the remaining keys. 90 | cursor, keys := k.extractCursorPos(input) 91 | 92 | if len(cursor) > 0 { 93 | k.cursor <- cursor 94 | } 95 | 96 | return keys, nil 97 | } 98 | } 99 | 100 | // rawReader translates Windows input to ANSI sequences, 101 | // to provide the same behavior as Unix terminals. 102 | type rawReader struct { 103 | ctrlKey bool 104 | altKey bool 105 | shiftKey bool 106 | } 107 | 108 | // newRawReader returns a new rawReader for Windows. 109 | func newRawReader() *rawReader { 110 | r := new(rawReader) 111 | return r 112 | } 113 | 114 | // Read reads input record from stdin on Windows. 115 | // It keeps reading until it gets a key event. 116 | func (r *rawReader) Read(buf []byte) (int, error) { 117 | ir := new(_INPUT_RECORD) 118 | var read int 119 | var err error 120 | 121 | next: 122 | // ReadConsoleInputW reads input record from stdin. 123 | err = kernel.ReadConsoleInputW(stdin, 124 | uintptr(unsafe.Pointer(ir)), 125 | 1, 126 | uintptr(unsafe.Pointer(&read)), 127 | ) 128 | if err != nil { 129 | return 0, err 130 | } 131 | 132 | // First deal with terminal focus events, which reset some stuff 133 | if ir.EventType == EVENT_FOCUS { 134 | ker := (*_FOCUS_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0])) 135 | 136 | // If are with Tab Active and losing the focus, 137 | // ignore this Alt key that is most likely the "Alt-Tab" 138 | // system shortcut on Windows for Tab switching. 139 | // QUESTION: Should we also do this to some other modifiers ? 140 | if !ker.bSetFocus && r.altKey { 141 | r.altKey = false 142 | goto next 143 | } 144 | } 145 | 146 | // Keep resize events for the display engine to use. 147 | if ir.EventType == EVENT_WINDOW_BUFFER_SIZE { 148 | return r.write(buf, WINDOWS_RESIZE) 149 | } 150 | 151 | if ir.EventType != EVENT_KEY { 152 | goto next 153 | } 154 | 155 | // Reset modifiers if key is released. 156 | ker := (*_KEY_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0])) 157 | if ker.bKeyDown == 0 { // keyup 158 | if r.ctrlKey || r.altKey || r.shiftKey { 159 | switch ker.wVirtualKeyCode { 160 | case VK_RCONTROL, VK_LCONTROL, VK_CONTROL: 161 | r.ctrlKey = false 162 | case VK_MENU: // alt 163 | r.altKey = false 164 | case VK_SHIFT, VK_LSHIFT, VK_RSHIFT: 165 | r.shiftKey = false 166 | } 167 | } 168 | goto next 169 | } 170 | 171 | // Keypad, special and arrow keys. 172 | if ker.unicodeChar == 0 { 173 | if modifiers, target := r.translateSeq(ker); target != 0 { 174 | return r.writeEsc(buf, append(modifiers, target)...) 175 | } 176 | goto next 177 | } 178 | 179 | char := rune(ker.unicodeChar) 180 | 181 | // Encode keys with modifiers. 182 | // Deal with the last (Windows) exceptions to the rule. 183 | switch { 184 | case r.shiftKey && char == charTab: 185 | return r.writeEsc(buf, 91, 90) 186 | case r.ctrlKey && char == charBackspace: 187 | char = charCtrlH 188 | case !r.ctrlKey && char == charCtrlH: 189 | char = charBackspace 190 | case r.ctrlKey: 191 | char = inputrc.Encontrol(char) 192 | case r.altKey: 193 | char = inputrc.Enmeta(char) 194 | } 195 | 196 | // Else, the key is a normal character. 197 | return r.write(buf, char) 198 | } 199 | 200 | // Close is a stub to satisfy io.Closer. 201 | func (r *rawReader) Close() error { 202 | return nil 203 | } 204 | 205 | func (r *rawReader) writeEsc(b []byte, char ...rune) (int, error) { 206 | b[0] = byte(inputrc.Esc) 207 | n := copy(b[1:], []byte(string(char))) 208 | return n + 1, nil 209 | } 210 | 211 | func (r *rawReader) write(b []byte, char ...rune) (int, error) { 212 | n := copy(b, []byte(string(char))) 213 | return n, nil 214 | } 215 | 216 | func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD) (modifiers []rune, target rune) { 217 | // Encode keys with modifiers by default, 218 | // unless the modifier is pressed alone. 219 | modifiers = append(modifiers, 91) 220 | 221 | // Modifiers add a default sequence, which is the good sequence for arrow keys by default. 222 | // The first rune is this sequence might be modified below, if the target is a special key 223 | // but not an arrow key. 224 | switch ker.wVirtualKeyCode { 225 | case VK_RCONTROL, VK_LCONTROL, VK_CONTROL: 226 | r.ctrlKey = true 227 | case VK_MENU: // alt 228 | r.altKey = true 229 | case VK_SHIFT, VK_LSHIFT, VK_RSHIFT: 230 | r.shiftKey = true 231 | } 232 | 233 | switch { 234 | case r.ctrlKey: 235 | modifiers = append(modifiers, 49, 59, 53) 236 | case r.altKey: 237 | modifiers = append(modifiers, 49, 59, 51) 238 | case r.shiftKey: 239 | modifiers = append(modifiers, 49, 59, 50) 240 | } 241 | 242 | changeModifiers := func(swap rune, pos int) { 243 | if len(modifiers) > pos-1 && pos > 0 { 244 | modifiers[pos] = swap 245 | } else { 246 | modifiers = append(modifiers, swap) 247 | } 248 | } 249 | 250 | // Now we handle the target key. 251 | switch ker.wVirtualKeyCode { 252 | // Keypad & arrow keys 253 | case VK_LEFT: 254 | target = 68 255 | case VK_RIGHT: 256 | target = 67 257 | case VK_UP: 258 | target = 65 259 | case VK_DOWN: 260 | target = 66 261 | case VK_HOME: 262 | target = 72 263 | case VK_END: 264 | target = 70 265 | 266 | // Other special keys, with effects on modifiers. 267 | case VK_SNAPSHOT: 268 | case VK_INSERT: 269 | changeModifiers(50, 2) 270 | target = 126 271 | case VK_DELETE: 272 | changeModifiers(51, 2) 273 | target = 126 274 | case VK_PRIOR: 275 | changeModifiers(53, 2) 276 | target = 126 277 | case VK_NEXT: 278 | changeModifiers(54, 2) 279 | target = 126 280 | } 281 | 282 | return 283 | } 284 | -------------------------------------------------------------------------------- /internal/display/display_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | // +build unix 3 | 4 | package display 5 | 6 | import ( 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | ) 11 | 12 | // WatchResize redisplays the interface on terminal resize events. 13 | func WatchResize(eng *Engine) chan<- bool { 14 | done := make(chan bool, 1) 15 | 16 | resizeChannel := make(chan os.Signal, 1) 17 | signal.Notify(resizeChannel, syscall.SIGWINCH) 18 | 19 | go func() { 20 | for { 21 | select { 22 | case <-resizeChannel: 23 | eng.completer.GenerateCached() 24 | eng.Refresh() 25 | case <-done: 26 | return 27 | } 28 | } 29 | }() 30 | 31 | return done 32 | } 33 | -------------------------------------------------------------------------------- /internal/display/display_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package display 5 | 6 | import ( 7 | "github.com/reeflective/readline/internal/core" 8 | ) 9 | 10 | // WatchResize redisplays the interface on terminal resize events on Windows. 11 | // Currently not implemented, see related issue in repo: too buggy right now. 12 | func WatchResize(eng *Engine) chan<- bool { 13 | resizeChannel := core.GetTerminalResize(eng.keys) 14 | done := make(chan bool, 1) 15 | 16 | go func() { 17 | for { 18 | select { 19 | case <-resizeChannel: 20 | // Weird behavior on Windows: when there is no autosuggested line, 21 | // the cursor moves at the end of the completions area, if non-empty. 22 | // We must manually go back to the input cursor position first. 23 | // LAST UPDATE: 02/08/25: On Windows 10 terminal, this seems to have 24 | // disappeared. 25 | // line, _ := eng.completer.Line() 26 | // if eng.completer.IsInserting() { 27 | // eng.suggested = *eng.line 28 | // } else { 29 | // eng.suggested = eng.histories.Suggest(eng.line) 30 | // } 31 | // 32 | // if eng.suggested.Len() <= line.Len() { 33 | // fmt.Println(term.HideCursor) 34 | // 35 | // compRows := completion.Coordinates(eng.completer) 36 | // if compRows <= eng.AvailableHelperLines() { 37 | // compRows++ 38 | // } 39 | // 40 | // term.MoveCursorBackwards(term.GetWidth()) 41 | // term.MoveCursorUp(compRows) 42 | // term.MoveCursorUp(ui.CoordinatesHint(eng.hint)) 43 | // eng.cursorHintToLineStart() 44 | // eng.lineStartToCursorPos() 45 | // fmt.Println(term.ShowCursor) 46 | // } 47 | // 48 | eng.completer.GenerateCached() 49 | eng.Refresh() 50 | case <-done: 51 | return 52 | } 53 | } 54 | }() 55 | 56 | return done 57 | } 58 | -------------------------------------------------------------------------------- /internal/display/highlight.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/reeflective/readline/internal/color" 11 | "github.com/reeflective/readline/internal/core" 12 | ) 13 | 14 | // highlightLine applies visual/selection highlighting to a line. 15 | // The provided line might already have been highlighted by a user-provided 16 | // highlighter: this function accounts for any embedded color sequences. 17 | func (e *Engine) highlightLine(line []rune, selection core.Selection) string { 18 | // Sort regions and extract colors/positions. 19 | sorted := sortHighlights(selection) 20 | colors := e.getHighlights(line, sorted) 21 | 22 | var highlighted string 23 | 24 | // And apply highlighting before each rune. 25 | for i, r := range line { 26 | if highlight, found := colors[i]; found { 27 | highlighted += string(highlight) 28 | } 29 | 30 | highlighted += string(r) 31 | } 32 | 33 | // Finally, highlight comments using a regex. 34 | comment := strings.Trim(e.opts.GetString("comment-begin"), "\"") 35 | commentPattern := fmt.Sprintf(`(^|\s)%s.*`, comment) 36 | 37 | if commentsMatch, err := regexp.Compile(commentPattern); err == nil { 38 | commentColor := color.SGRStart + color.Fg + "244" + color.SGREnd 39 | highlighted = commentsMatch.ReplaceAllString(highlighted, fmt.Sprintf("%s${0}%s", commentColor, color.Reset)) 40 | } 41 | 42 | highlighted += color.Reset 43 | 44 | return highlighted 45 | } 46 | 47 | func sortHighlights(vhl core.Selection) []core.Selection { 48 | all := make([]core.Selection, 0) 49 | sorted := make([]core.Selection, 0) 50 | bpos := make([]int, 0) 51 | 52 | for _, reg := range vhl.Surrounds() { 53 | all = append(all, reg) 54 | rbpos, _ := reg.Pos() 55 | bpos = append(bpos, rbpos) 56 | } 57 | 58 | if vhl.Active() && vhl.IsVisual() { 59 | all = append(all, vhl) 60 | vbpos, _ := vhl.Pos() 61 | bpos = append(bpos, vbpos) 62 | } 63 | 64 | sort.Ints(bpos) 65 | 66 | prevIsMatcher := false 67 | prevPos := 0 68 | 69 | for _, pos := range bpos { 70 | for _, reg := range all { 71 | bpos, _ := reg.Pos() 72 | isMatcher := reg.Type == "matcher" 73 | 74 | if bpos != pos || !reg.Active() || !reg.IsVisual() { 75 | continue 76 | } 77 | 78 | // If we have both a matcher and a visual selection 79 | // starting at the same position, then we might have 80 | // just added the matcher, and we must "overwrite" it 81 | // with the visual selection, so skip until we find it. 82 | if prevIsMatcher && isMatcher && prevPos == pos { 83 | continue 84 | } 85 | 86 | // Else the region is good to be used in that order. 87 | sorted = append(sorted, reg) 88 | prevIsMatcher = reg.Type == "matcher" 89 | prevPos = bpos 90 | 91 | break 92 | } 93 | } 94 | 95 | return sorted 96 | } 97 | 98 | func (e *Engine) getHighlights(line []rune, sorted []core.Selection) map[int][]rune { 99 | highlights := make(map[int][]rune) 100 | 101 | // Find any highlighting already applied on the line, 102 | // and keep the indexes so that we can skip those. 103 | var colors [][]int 104 | 105 | colorMatch := regexp.MustCompile(`\x1b\[[0-9;]+m`) 106 | colors = colorMatch.FindAllStringIndex(string(line), -1) 107 | 108 | // marks that started highlighting, but not done yet. 109 | regions := make([]core.Selection, 0) 110 | pos := -1 111 | skip := 0 112 | 113 | // Build the string. 114 | for rawIndex := range line { 115 | var posHl []rune 116 | var newHl core.Selection 117 | 118 | // While in a color escape, keep reading runes. 119 | if skip > 0 { 120 | skip-- 121 | continue 122 | } 123 | 124 | // If starting a color escape code, add offset and read. 125 | if len(colors) > 0 && colors[0][0] == rawIndex { 126 | skip += colors[0][1] - colors[0][0] - 1 127 | colors = colors[1:] 128 | 129 | continue 130 | } 131 | 132 | // Or we are reading a printed rune. 133 | pos++ 134 | 135 | // First check if we have a new highlighter to apply 136 | for _, hl := range sorted { 137 | bpos, _ := hl.Pos() 138 | 139 | if bpos == pos { 140 | newHl = hl 141 | regions = append(regions, hl) 142 | } 143 | } 144 | 145 | // Add new colors if any, and reset if some are done. 146 | regions, posHl = e.hlReset(regions, posHl, pos) 147 | posHl = e.hlAdd(regions, newHl, posHl) 148 | 149 | // Add to the line, with the raw index since 150 | // we must take into account embedded colors. 151 | if len(posHl) > 0 { 152 | highlights[rawIndex] = posHl 153 | } 154 | } 155 | 156 | return highlights 157 | } 158 | 159 | func (e *Engine) hlAdd(regions []core.Selection, newHl core.Selection, line []rune) []rune { 160 | var ( 161 | fg, bg string 162 | matcher bool 163 | hl core.Selection 164 | ) 165 | 166 | if newHl.Active() { 167 | hl = newHl 168 | } else if len(regions) > 0 { 169 | hl = regions[len(regions)-1] 170 | } 171 | 172 | fg, bg = hl.Highlights() 173 | matcher = hl.Type == "matcher" 174 | 175 | // Update the highlighting with inputrc settings if any. 176 | if bg != "" && !matcher { 177 | background := color.UnquoteRC("active-region-start-color") 178 | if bg, _ = strconv.Unquote(background); bg == "" { 179 | bg = color.Reverse 180 | } 181 | } 182 | 183 | // Add highlightings 184 | line = append(line, []rune(bg)...) 185 | line = append(line, []rune(fg)...) 186 | 187 | return line 188 | } 189 | 190 | func (e *Engine) hlReset(regions []core.Selection, line []rune, pos int) ([]core.Selection, []rune) { 191 | for i, reg := range regions { 192 | _, epos := reg.Pos() 193 | foreground, background := reg.Highlights() 194 | // matcher := reg.Type == "matcher" 195 | 196 | if epos != pos { 197 | continue 198 | } 199 | 200 | if i < len(regions)-1 { 201 | regions = append(regions[:i], regions[i+1:]...) 202 | } else { 203 | regions = regions[:i] 204 | } 205 | 206 | if foreground != "" { 207 | line = append(line, []rune(color.FgDefault)...) 208 | } 209 | 210 | if background != "" { 211 | // background, _ := strconv.Unquote(e.opts.GetString("active-region-end-color")) 212 | // foreground := e.opts.GetString("active-region-start-color") 213 | line = append(line, []rune(color.ReverseReset)...) 214 | line = append(line, []rune(color.BgDefault)...) 215 | // if background == "" && foreground == "" && !matcher { 216 | // line = append(line, []rune(color.ReverseReset)...) 217 | // } else { 218 | // 219 | // line = append(line, []rune(color.BgDefault)...) 220 | // } 221 | // 222 | // line = append(line, []rune(color.ReverseReset)...) 223 | } 224 | } 225 | 226 | return regions, line 227 | } 228 | -------------------------------------------------------------------------------- /internal/editor/editor.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package editor 5 | 6 | import ( 7 | "crypto/md5" 8 | "encoding/hex" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "os" 13 | "path/filepath" 14 | "strconv" 15 | "time" 16 | ) 17 | 18 | var ( 19 | // ErrNoTempDirectory indicates that Go's standard os.TempDir() did not return a directory. 20 | ErrNoTempDirectory = errors.New("could not identify the temp directory on this system") 21 | // ErrWrite indicates that we failed to write the buffer to the file. 22 | ErrWrite = errors.New("failed to write buffer to file") 23 | // ErrCreate indicates that we failed to create the temp buffer file. 24 | ErrCreate = errors.New("failed to create buffer file") 25 | // ErrOpen indicates that we failed to open the buffer file. 26 | ErrOpen = errors.New("failed to open buffer file") 27 | // ErrRemove indicates that we failed to delete the buffer file. 28 | ErrRemove = errors.New("failed to remove buffer file") 29 | // ErrRead indicates that we failed to read the buffer file's content. 30 | ErrRead = errors.New("failed to read buffer file") 31 | ) 32 | 33 | func writeToFile(buf []byte, filename string) (string, error) { 34 | var path string 35 | 36 | // Get the temp directory, or fail. 37 | tmp := os.TempDir() 38 | if tmp == "" { 39 | return "", ErrNoTempDirectory 40 | } 41 | 42 | // If the user has not provided any filename (including an extension) 43 | // we generate a random filename with no extension. 44 | if filename == "" { 45 | fileID := strconv.Itoa(time.Now().Nanosecond()) + ":" + string(buf) 46 | 47 | h := md5.New() 48 | 49 | _, err := h.Write([]byte(fileID)) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | name := "readline-" + hex.EncodeToString(h.Sum(nil)) + "-" + strconv.Itoa(os.Getpid()) 55 | path = filepath.Join(tmp, name) 56 | } else { 57 | // Else, still use the temp/ dir, but with the provided filename 58 | path = filepath.Join(tmp, filename) 59 | } 60 | 61 | file, err := os.Create(path) 62 | if err != nil { 63 | return "", fmt.Errorf("%w: %s", ErrCreate, err.Error()) 64 | } 65 | 66 | defer file.Close() 67 | 68 | _, err = file.Write(buf) 69 | if err != nil { 70 | return "", fmt.Errorf("%w: %s", ErrWrite, err.Error()) 71 | } 72 | 73 | return path, nil 74 | } 75 | 76 | func readTempFile(name string) ([]byte, error) { 77 | file, err := os.Open(name) 78 | if err != nil { 79 | return nil, fmt.Errorf("%w: %s", ErrOpen, err.Error()) 80 | } 81 | 82 | buf, err := io.ReadAll(file) 83 | if err != nil { 84 | return nil, fmt.Errorf("%w: %s", ErrRead, err.Error()) 85 | } 86 | 87 | if len(buf) > 0 && buf[len(buf)-1] == '\n' { 88 | buf = buf[:len(buf)-1] 89 | } 90 | 91 | if len(buf) > 0 && buf[len(buf)-1] == '\r' { 92 | buf = buf[:len(buf)-1] 93 | } 94 | 95 | if len(buf) > 0 && buf[len(buf)-1] == '\n' { 96 | buf = buf[:len(buf)-1] 97 | } 98 | 99 | if len(buf) > 0 && buf[len(buf)-1] == '\r' { 100 | buf = buf[:len(buf)-1] 101 | } 102 | 103 | if err = os.Remove(name); err != nil { 104 | return nil, fmt.Errorf("%w: %s", ErrRemove, err.Error()) 105 | } 106 | 107 | return buf, nil 108 | } 109 | 110 | func getSystemEditor(emacsDefault bool) (editor string) { 111 | editor = os.Getenv("VISUAL") 112 | if editor == "" { 113 | return 114 | } 115 | 116 | editor = os.Getenv("EDITOR") 117 | if editor == "" { 118 | return 119 | } 120 | 121 | if emacsDefault { 122 | return "emacs" 123 | } 124 | 125 | return "vi" 126 | } 127 | -------------------------------------------------------------------------------- /internal/editor/editor_plan9.go: -------------------------------------------------------------------------------- 1 | //go:build plan9 2 | // +build plan9 3 | 4 | package editor 5 | 6 | import "errors" 7 | 8 | // EditBuffer is currently not supported on Plan9 operating systems. 9 | func (reg *Buffers) EditBuffer(buf []rune, filename, filetype string, emacs bool) ([]rune, error) { 10 | return buf, errors.New("Not currently supported on Plan 9") 11 | } 12 | -------------------------------------------------------------------------------- /internal/editor/editor_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !plan9 2 | 3 | package editor 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | ) 11 | 12 | // ErrStart indicates that the command to start the editor failed. 13 | var ErrStart = errors.New("failed to start editor") 14 | 15 | // EditBuffer starts the system editor and opens the given buffer in it. 16 | // If the filename is specified, the file will be created in the system 17 | // temp directory under this name. 18 | // If the filetype is not empty and if the system editor supports it, the 19 | // file will be opened with the specified filetype passed to the editor. 20 | func (reg *Buffers) EditBuffer(buf []rune, filename, filetype string, emacs bool) ([]rune, error) { 21 | name, err := writeToFile([]byte(string(buf)), filename) 22 | if err != nil { 23 | return buf, err 24 | } 25 | 26 | editor := getSystemEditor(emacs) 27 | 28 | args := []string{} 29 | if filetype != "" { 30 | args = append(args, fmt.Sprintf("-c 'set filetype=%s", filetype)) 31 | } 32 | 33 | args = append(args, name) 34 | 35 | cmd := exec.Command(editor, args...) 36 | 37 | cmd.Stdin = os.Stdin 38 | cmd.Stdout = os.Stdout 39 | cmd.Stderr = os.Stderr 40 | 41 | if err = cmd.Start(); err != nil { 42 | return buf, fmt.Errorf("%w: %s", ErrStart, err.Error()) 43 | } 44 | 45 | if err = cmd.Wait(); err != nil { 46 | return buf, fmt.Errorf("%w: %s", ErrStart, err.Error()) 47 | } 48 | 49 | b, err := readTempFile(name) 50 | 51 | return []rune(string(b)), err 52 | } 53 | -------------------------------------------------------------------------------- /internal/editor/editor_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package editor 5 | 6 | import "errors" 7 | 8 | // EditBuffer is currently not supported on Windows operating systems. 9 | func (reg *Buffers) EditBuffer(buf []rune, filename, filetype string, emacs bool) ([]rune, error) { 10 | return buf, errors.New("Not currently supported on Windows") 11 | } 12 | -------------------------------------------------------------------------------- /internal/history/file.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | errOpenHistoryFile = errors.New("failed to open history file") 15 | errNegativeIndex = errors.New("cannot use a negative index when requesting historic commands") 16 | errOutOfRangeIndex = errors.New("index requested greater than number of items in history") 17 | ) 18 | 19 | // fileHistory provides a history source based on a file. 20 | type fileHistory struct { 21 | file string 22 | lines []Item 23 | } 24 | 25 | // Item is the structure of an individual item in the History.list slice. 26 | type Item struct { 27 | Index int 28 | DateTime time.Time 29 | Block string 30 | } 31 | 32 | // NewSourceFromFile returns a new history source writing to and reading from a file. 33 | func NewSourceFromFile(file string) (Source, error) { 34 | var err error 35 | 36 | hist := new(fileHistory) 37 | hist.file = file 38 | hist.lines, err = openHist(file) 39 | 40 | return hist, err 41 | } 42 | 43 | func openHist(filename string) (list []Item, err error) { 44 | file, err := os.Open(filename) 45 | if err != nil { 46 | return list, fmt.Errorf("%w: %s", errOpenHistoryFile, err.Error()) 47 | } 48 | 49 | scanner := bufio.NewScanner(file) 50 | for scanner.Scan() { 51 | var item Item 52 | 53 | err := json.Unmarshal(scanner.Bytes(), &item) 54 | if err != nil || len(item.Block) == 0 { 55 | continue 56 | } 57 | 58 | item.Index = len(list) 59 | list = append(list, item) 60 | } 61 | 62 | file.Close() 63 | 64 | return list, nil 65 | } 66 | 67 | // Write item to history file. 68 | func (h *fileHistory) Write(s string) (int, error) { 69 | block := strings.TrimSpace(s) 70 | if block == "" { 71 | return 0, nil 72 | } 73 | 74 | item := Item{ 75 | DateTime: time.Now(), 76 | Block: block, 77 | Index: len(h.lines), 78 | } 79 | 80 | if len(h.lines) == 0 || h.lines[len(h.lines)-1].Block != block { 81 | h.lines = append(h.lines, item) 82 | } 83 | 84 | line := struct { 85 | DateTime time.Time `json:"datetime"` 86 | Block string `json:"block"` 87 | }{ 88 | Block: block, 89 | DateTime: item.DateTime, 90 | } 91 | 92 | data, err := json.Marshal(line) 93 | if err != nil { 94 | return h.Len(), err 95 | } 96 | 97 | f, err := os.OpenFile(h.file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) 98 | if err != nil { 99 | return 0, fmt.Errorf("%w: %s", errOpenHistoryFile, err.Error()) 100 | } 101 | 102 | _, err = f.Write(append(data, '\n')) 103 | f.Close() 104 | 105 | return h.Len(), err 106 | } 107 | 108 | // GetLine returns a specific line from the history file. 109 | func (h *fileHistory) GetLine(pos int) (string, error) { 110 | if pos < 0 { 111 | return "", errNegativeIndex 112 | } 113 | 114 | if pos < len(h.lines) { 115 | return h.lines[pos].Block, nil 116 | } 117 | 118 | return "", errOutOfRangeIndex 119 | } 120 | 121 | // Len returns the number of items in the history file. 122 | func (h *fileHistory) Len() int { 123 | return len(h.lines) 124 | } 125 | 126 | // Dump returns the entire history file. 127 | func (h *fileHistory) Dump() interface{} { 128 | return h.lines 129 | } 130 | -------------------------------------------------------------------------------- /internal/history/history.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | var defaultSourceName = "default history" 4 | 5 | // Source is an interface to allow you to write your own history logging tools. 6 | // By default readline will just use the dummyLineHistory interface which only 7 | // logs the history to memory ([]string to be precise). 8 | type Source interface { 9 | // Append takes the line and returns an updated number of lines or an error 10 | Write(string) (int, error) 11 | 12 | // GetLine takes the historic line number and returns the line or an error 13 | GetLine(int) (string, error) 14 | 15 | // Len returns the number of history lines 16 | Len() int 17 | 18 | // Dump returns everything in readline. The return is an interface{} because 19 | // not all LineHistory implementations will want to structure the history in 20 | // the same way. And since Dump() is not actually used by the readline API 21 | // internally, this methods return can be structured in whichever way is most 22 | // convenient for your own applications (or even just create an empty 23 | // function which returns `nil` if you don't require Dump() either) 24 | Dump() interface{} 25 | } 26 | 27 | // memory is an in memory history. 28 | // One such history is bound to the readline shell by default. 29 | type memory struct { 30 | items []string 31 | } 32 | 33 | // NewInMemoryHistory creates a new in-memory command history source. 34 | func NewInMemoryHistory() Source { 35 | return new(memory) 36 | } 37 | 38 | // Write to history. 39 | func (h *memory) Write(s string) (int, error) { 40 | h.items = append(h.items, s) 41 | return len(h.items), nil 42 | } 43 | 44 | // GetLine returns a line from history. 45 | func (h *memory) GetLine(i int) (string, error) { 46 | if len(h.items) == 0 { 47 | return "", nil 48 | } 49 | 50 | return h.items[i], nil 51 | } 52 | 53 | // Len returns the number of lines in history. 54 | func (h *memory) Len() int { 55 | return len(h.items) 56 | } 57 | 58 | // Dump returns the entire history. 59 | func (h *memory) Dump() interface{} { 60 | return h.items 61 | } 62 | -------------------------------------------------------------------------------- /internal/history/undo.go: -------------------------------------------------------------------------------- 1 | package history 2 | 3 | import ( 4 | "github.com/reeflective/readline/inputrc" 5 | "github.com/reeflective/readline/internal/core" 6 | ) 7 | 8 | // lineHistory contains all state changes for a given input line, 9 | // whether it is the current input line or one of the history ones. 10 | type lineHistory struct { 11 | pos int 12 | items []undoItem 13 | } 14 | 15 | type undoItem struct { 16 | line string 17 | pos int 18 | } 19 | 20 | // Save saves the current line and cursor position as an undo state item. 21 | // If this was called while the shell was in the middle of its undo history 22 | // (eg. the caller has undone one or more times), all undone steps are dropped. 23 | func (h *Sources) Save() { 24 | defer h.Reset() 25 | 26 | if h.skip { 27 | return 28 | } 29 | 30 | // Get the undo states for the current line. 31 | line := h.getLineHistory() 32 | if line == nil { 33 | return 34 | } 35 | 36 | // When the line is identical to the previous undo, we just update 37 | // the cursor position if it's a different one. 38 | if len(line.items) > 0 && line.items[len(line.items)-1].line == string(*h.line) { 39 | line.items[len(line.items)-1].pos = h.cursor.Pos() 40 | return 41 | } 42 | 43 | // When we add an item to the undo history, the history 44 | // is cut from the current undo hist position onwards. 45 | if line.pos > len(line.items) { 46 | line.pos = len(line.items) 47 | } 48 | 49 | line.items = line.items[:len(line.items)-line.pos] 50 | 51 | // Make a copy of the cursor and ensure its position. 52 | cur := core.NewCursor(h.line) 53 | cur.Set(h.cursor.Pos()) 54 | cur.CheckCommand() 55 | 56 | // And save the item. 57 | line.items = append(line.items, undoItem{ 58 | line: string(*h.line), 59 | pos: cur.Pos(), 60 | }) 61 | } 62 | 63 | // SkipSave will not save the current line when the target command is done 64 | // (more precisely, the next call to h.Save() will have no effect). 65 | // This function is not useful is most cases, as call to saves will efficiently 66 | // compare the line with the last saved state, and will not add redundant ones. 67 | func (h *Sources) SkipSave() { 68 | h.skip = true 69 | } 70 | 71 | // SaveWithCommand is only meant to be called in the main readline loop of the shell, 72 | // and not from within commands themselves: it does the same job as Save(), but also 73 | // keeps the command that has just been executed. 74 | func (h *Sources) SaveWithCommand(bind inputrc.Bind) { 75 | h.last = bind 76 | h.Save() 77 | } 78 | 79 | // Undo restores the line and cursor position to their last saved state. 80 | func (h *Sources) Undo() { 81 | h.skip = true 82 | h.undoing = true 83 | 84 | // Get the undo states for the current line. 85 | line := h.getLineHistory() 86 | if line == nil || len(line.items) == 0 { 87 | return 88 | } 89 | 90 | var undo undoItem 91 | 92 | // When undoing, we loop through preceding undo items 93 | // as long as they are identical to the current line. 94 | for { 95 | line.pos++ 96 | 97 | // Exit if we reached the end. 98 | if line.pos > len(line.items) { 99 | line.pos = len(line.items) 100 | return 101 | } 102 | 103 | // Break as soon as we find a non-matching line. 104 | undo = line.items[len(line.items)-line.pos] 105 | if undo.line != string(*h.line) { 106 | break 107 | } 108 | } 109 | 110 | // Use the undo we found 111 | h.line.Set([]rune(undo.line)...) 112 | h.cursor.Set(undo.pos) 113 | } 114 | 115 | // Revert goes back to the initial state of the line, which is what it was 116 | // like when the shell started reading user input. Note that this state might 117 | // be a line that was inferred, accept-and-held from the previous readline run. 118 | func (h *Sources) Revert() { 119 | line := h.getLineHistory() 120 | if line == nil || len(line.items) == 0 { 121 | return 122 | } 123 | 124 | // Reuse the first saved state. 125 | undo := line.items[0] 126 | 127 | h.line.Set([]rune(undo.line)...) 128 | h.cursor.Set(undo.pos) 129 | 130 | // And reset everything 131 | line.items = make([]undoItem, 0) 132 | 133 | h.Reset() 134 | } 135 | 136 | // Redo cancels an undo action if any has been made, or if 137 | // at the begin of the undo history, restores the original 138 | // line's contents as their were before starting undoing. 139 | func (h *Sources) Redo() { 140 | h.skip = true 141 | h.undoing = true 142 | 143 | line := h.getLineHistory() 144 | if line == nil || len(line.items) == 0 { 145 | return 146 | } 147 | 148 | line.pos-- 149 | 150 | if line.pos < 1 { 151 | return 152 | } 153 | 154 | undo := line.items[len(line.items)-line.pos] 155 | h.line.Set([]rune(undo.line)...) 156 | h.cursor.Set(undo.pos) 157 | } 158 | 159 | // Last returns the last command ran by the shell. 160 | func (h *Sources) Last() inputrc.Bind { 161 | return h.last 162 | } 163 | 164 | // Pos returns the current position in the undo history, which is 165 | // equal to its length minus the number of previous undo calls. 166 | func (h *Sources) Pos() int { 167 | lh := h.getLineHistory() 168 | if lh == nil { 169 | return 0 170 | } 171 | 172 | return lh.pos 173 | } 174 | 175 | // Reset will reset the current position in the list 176 | // of undo items, but will not delete any of them. 177 | func (h *Sources) Reset() { 178 | h.skip = false 179 | 180 | line := h.getLineHistory() 181 | if line == nil { 182 | return 183 | } 184 | 185 | if !h.undoing { 186 | line.pos = 0 187 | } 188 | 189 | h.undoing = false 190 | } 191 | 192 | // Always returns a non-nil map, whether or not a history source is found. 193 | func (h *Sources) getHistoryLineChanges() map[int]*lineHistory { 194 | history := h.Current() 195 | if history == nil { 196 | return map[int]*lineHistory{} 197 | } 198 | 199 | // Get the state changes of all history lines 200 | // for the current history source. 201 | source := h.names[h.sourcePos] 202 | 203 | hist := h.lines[source] 204 | if hist == nil { 205 | h.lines[source] = make(map[int]*lineHistory) 206 | hist = h.lines[source] 207 | } 208 | 209 | return hist 210 | } 211 | 212 | func (h *Sources) getLineHistory() *lineHistory { 213 | hist := h.getHistoryLineChanges() 214 | if hist == nil { 215 | return &lineHistory{} 216 | } 217 | 218 | // Compute the position of the current line in the history. 219 | linePos := -1 220 | 221 | history := h.Current() 222 | if h.hpos > -1 && history != nil { 223 | linePos = history.Len() - h.hpos 224 | } 225 | 226 | if hist[linePos] == nil { 227 | hist[linePos] = &lineHistory{} 228 | } 229 | 230 | // Return the state changes of the current line. 231 | return hist[linePos] 232 | } 233 | 234 | func (h *Sources) restoreLineBuffer() { 235 | h.hpos = -1 236 | 237 | hist := h.getHistoryLineChanges() 238 | if hist == nil { 239 | return 240 | } 241 | 242 | // Get the undo states for the line buffer 243 | // (the last one, not any of the history ones) 244 | lh := hist[h.hpos] 245 | if lh == nil || len(lh.items) == 0 { 246 | return 247 | } 248 | 249 | undo := lh.items[len(lh.items)-1] 250 | 251 | // Restore the line to the last known state. 252 | h.line.Set([]rune(undo.line)...) 253 | h.cursor.Set(undo.pos) 254 | } 255 | -------------------------------------------------------------------------------- /internal/keymap/completion.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/reeflective/readline/inputrc" 7 | ) 8 | 9 | // menuselectKeys are the default keymaps in menuselect mode. 10 | var menuselectKeys = map[string]inputrc.Bind{ 11 | unescape(`\C-i`): {Action: "menu-complete"}, 12 | unescape(`\C-N`): {Action: "menu-complete"}, 13 | unescape(`\C-P`): {Action: "menu-complete-backward"}, 14 | unescape(`\e[Z`): {Action: "menu-complete-backward"}, 15 | unescape(`\C-@`): {Action: "accept-and-menu-complete"}, 16 | unescape(`\C-F`): {Action: "menu-incremental-search"}, 17 | unescape(`\e[A`): {Action: "menu-complete-backward"}, 18 | unescape(`\e[B`): {Action: "menu-complete"}, 19 | unescape(`\e[C`): {Action: "menu-complete"}, 20 | unescape(`\e[D`): {Action: "menu-complete-backward"}, 21 | unescape(`\e[1;5A`): {Action: "menu-complete-prev-tag"}, 22 | unescape(`\e[1;5B`): {Action: "menu-complete-next-tag"}, 23 | } 24 | 25 | // isearchCommands is a subset of commands that are valid in incremental-search mode. 26 | var isearchCommands = []string{ 27 | // Edition 28 | "abort", 29 | "backward-delete-char", 30 | "backward-kill-word", 31 | "backward-kill-line", 32 | "unix-line-discard", 33 | "unix-word-rubout", 34 | "vi-unix-word-rubout", 35 | "clear-screen", 36 | "clear-display", 37 | "magic-space", 38 | "vi-movement-mode", 39 | "yank", 40 | "self-insert", 41 | 42 | // History 43 | "accept-and-infer-next-history", 44 | "accept-line", 45 | "accept-and-hold", 46 | "operate-and-get-next", 47 | "history-incremental-search-forward", 48 | "history-incremental-search-backward", 49 | "forward-search-history", 50 | "reverse-search-history", 51 | "history-search-forward", 52 | "history-search-backward", 53 | "history-substring-search-forward", 54 | "history-substring-search-backward", 55 | "incremental-forward-search-history", 56 | "incremental-reverse-search-history", 57 | } 58 | 59 | // nonIsearchCommands is an even more restricted set of commands 60 | // that are used when a non-incremental search mode is active. 61 | var nonIsearchCommands = []string{ 62 | "abort", 63 | "accept-line", 64 | "backward-delete-char", 65 | "backward-kill-word", 66 | "backward-kill-line", 67 | "unix-line-discard", 68 | "unix-word-rubout", 69 | "vi-unix-word-rubout", 70 | "self-insert", 71 | } 72 | 73 | // getContextBinds is in charge of returning the precise list of binds 74 | // that are relevant in a given context (local/main keymap). Some submodes 75 | // (like non/incremental search) will further restrict the set of binds. 76 | func (m *Engine) getContextBinds(main bool) (binds map[string]inputrc.Bind) { 77 | // First get the unfiltered list 78 | // of binds for the current keymap. 79 | if main { 80 | binds = m.config.Binds[string(m.main)] 81 | } else { 82 | binds = m.config.Binds[string(m.local)] 83 | } 84 | 85 | // No filtering possible on the local keymap, or if no binds. 86 | if !main || len(binds) == 0 { 87 | return 88 | } 89 | 90 | // Then possibly restrict in some submodes. 91 | switch { 92 | case m.Local() == Isearch: 93 | binds = m.restrictCommands(m.main, isearchCommands) 94 | case m.nonIncSearch: 95 | binds = m.restrictCommands(m.main, nonIsearchCommands) 96 | } 97 | 98 | return 99 | } 100 | 101 | func (m *Engine) restrictCommands(mode Mode, commands []string) map[string]inputrc.Bind { 102 | if len(commands) == 0 { 103 | return m.config.Binds[string(mode)] 104 | } 105 | 106 | isearch := make(map[string]inputrc.Bind) 107 | 108 | for seq, command := range m.config.Binds[string(mode)] { 109 | // Widget must be a valid isearch widget 110 | if !isValidCommand(command.Action, commands) { 111 | continue 112 | } 113 | 114 | // Or bind to our temporary isearch keymap 115 | isearch[seq] = command 116 | } 117 | 118 | return isearch 119 | } 120 | 121 | func isValidCommand(widget string, commands []string) bool { 122 | return slices.Contains(commands, widget) 123 | } 124 | -------------------------------------------------------------------------------- /internal/keymap/config.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/reeflective/readline/inputrc" 11 | ) 12 | 13 | // readline global options specific to this library. 14 | var readlineOptions = map[string]interface{}{ 15 | // General edition 16 | "autopairs": false, 17 | 18 | // Completion 19 | "autocomplete": false, 20 | "completion-list-separator": "--", 21 | "completion-selection-style": "\x1b[1;30m", 22 | 23 | // Prompt & General UI 24 | "transient-prompt": false, 25 | "usage-hint-always": false, 26 | "history-autosuggest": false, 27 | "multiline-column": true, 28 | "multiline-column-numbered": false, 29 | } 30 | 31 | // ReloadConfig parses all valid .inputrc configurations and immediately 32 | // updates/reloads all related settings (editing mode, variables behavior, etc.) 33 | func (m *Engine) ReloadConfig(opts ...inputrc.Option) (err error) { 34 | // Builtin Go binds (in addition to default readline binds) 35 | m.loadBuiltinOptions() 36 | m.loadBuiltinBinds() 37 | 38 | user, _ := user.Current() 39 | 40 | // Parse library-specific configurations. 41 | // 42 | // This library implements various additional commands and keymaps. 43 | // Parse the configuration with a specific App name, ignoring errors. 44 | inputrc.UserDefault(user, m.config, inputrc.WithApp("go")) 45 | 46 | // Parse user configurations. 47 | // 48 | // Those default settings are the base options often needed 49 | // by /etc/inputrc on various Linux distros (for special keys). 50 | defaults := []inputrc.Option{ 51 | inputrc.WithMode("emacs"), 52 | inputrc.WithTerm(os.Getenv("TERM")), 53 | } 54 | 55 | opts = append(defaults, opts...) 56 | 57 | // This will only overwrite binds that have been 58 | // set in those configs, and leave the default ones 59 | // (those just set above), so as to keep most of the 60 | // default functionality working out of the box. 61 | err = inputrc.UserDefault(user, m.config, opts...) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // Some configuration variables might have an 67 | // effect on our various keymaps and bindings. 68 | m.overrideBindsSpecial() 69 | 70 | // Startup editing mode 71 | switch m.config.GetString("editing-mode") { 72 | case "emacs": 73 | m.main = Emacs 74 | case "vi": 75 | m.main = ViInsert 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // loadBuiltinOptions loads some options specific to 82 | // this library, if they are not loaded already. 83 | func (m *Engine) loadBuiltinOptions() { 84 | for name, value := range readlineOptions { 85 | if val := m.config.Get(name); val == nil { 86 | m.config.Set(name, value) 87 | } 88 | } 89 | } 90 | 91 | // loadBuiltinBinds adds additional command mappings that are not part 92 | // of the standard C readline configuration: those binds therefore can 93 | // reference commands or keymaps only implemented/used in this library. 94 | func (m *Engine) loadBuiltinBinds() { 95 | // Emacs specials 96 | for seq, bind := range emacsKeys { 97 | m.config.Binds[string(Emacs)][seq] = bind 98 | } 99 | 100 | // Vim main keymaps 101 | for seq, bind := range vicmdKeys { 102 | m.config.Binds[string(ViCommand)][seq] = bind 103 | m.config.Binds[string(ViMove)][seq] = bind 104 | m.config.Binds[string(Vi)][seq] = bind 105 | } 106 | 107 | for seq, bind := range viinsKeys { 108 | m.config.Binds[string(ViInsert)][seq] = bind 109 | } 110 | 111 | // Vim local keymaps 112 | m.config.Binds[string(Visual)] = visualKeys 113 | m.config.Binds[string(ViOpp)] = vioppKeys 114 | m.config.Binds[string(MenuSelect)] = menuselectKeys 115 | m.config.Binds[string(Isearch)] = menuselectKeys 116 | 117 | // Default TTY binds 118 | for _, keymap := range m.config.Binds { 119 | keymap[inputrc.Unescape(`\C-C`)] = inputrc.Bind{Action: "abort"} 120 | } 121 | } 122 | 123 | // overrideBindsSpecial overwrites some binds as dictated by the configuration variables. 124 | func (m *Engine) overrideBindsSpecial() { 125 | // Disable completion functions if required 126 | if m.config.GetBool("disable-completion") { 127 | for _, keymap := range m.config.Binds { 128 | for seq, bind := range keymap { 129 | switch bind.Action { 130 | case "complete", "menu-complete", "possible-completions": 131 | keymap[seq] = inputrc.Bind{Action: "self-insert"} 132 | } 133 | } 134 | } 135 | } 136 | } 137 | 138 | func printBindsReadable(commands []string, all map[string][]string) { 139 | for _, command := range commands { 140 | commandBinds := all[command] 141 | sort.Strings(commandBinds) 142 | 143 | switch { 144 | case len(commandBinds) == 0: 145 | case len(commandBinds) > 5: 146 | var firstBinds []string 147 | 148 | for i := range 5 { 149 | firstBinds = append(firstBinds, "\""+commandBinds[i]+"\"") 150 | } 151 | 152 | bindsStr := strings.Join(firstBinds, ", ") 153 | fmt.Printf("%s can be found on %s ...\n", command, bindsStr) 154 | 155 | default: 156 | var firstBinds []string 157 | 158 | for _, bind := range commandBinds { 159 | firstBinds = append(firstBinds, "\""+bind+"\"") 160 | } 161 | 162 | bindsStr := strings.Join(firstBinds, ", ") 163 | fmt.Printf("%s can be found on %s\n", command, bindsStr) 164 | } 165 | } 166 | } 167 | 168 | func printBindsInputrc(commands []string, all map[string][]string) { 169 | for _, command := range commands { 170 | commandBinds := all[command] 171 | sort.Strings(commandBinds) 172 | 173 | if len(commandBinds) > 0 { 174 | for _, bind := range commandBinds { 175 | fmt.Printf("\"%s\": %s\n", bind, command) 176 | } 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /internal/keymap/cursor.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // CursorStyle is the style of the cursor 9 | // in a given input mode/submode. 10 | type CursorStyle string 11 | 12 | // String - Implements fmt.Stringer. 13 | func (c CursorStyle) String() string { 14 | cursor, found := cursors[c] 15 | if !found { 16 | return string(cursorUserDefault) 17 | } 18 | 19 | return cursor 20 | } 21 | 22 | const ( 23 | cursorBlock CursorStyle = "block" 24 | cursorUnderline CursorStyle = "underline" 25 | cursorBeam CursorStyle = "beam" 26 | cursorBlinkingBlock CursorStyle = "blinking-block" 27 | cursorBlinkingUnderline CursorStyle = "blinking-underline" 28 | cursorBlinkingBeam CursorStyle = "blinking-beam" 29 | cursorUserDefault CursorStyle = "default" 30 | ) 31 | 32 | var cursors = map[CursorStyle]string{ 33 | cursorBlock: "\x1b[2 q", 34 | cursorUnderline: "\x1b[4 q", 35 | cursorBeam: "\x1b[6 q", 36 | cursorBlinkingBlock: "\x1b[1 q", 37 | cursorBlinkingUnderline: "\x1b[3 q", 38 | cursorBlinkingBeam: "\x1b[5 q", 39 | cursorUserDefault: "\x1b[0 q", 40 | } 41 | 42 | var defaultCursors = map[Mode]CursorStyle{ 43 | ViInsert: cursorBlinkingBeam, 44 | Vi: cursorBlinkingBeam, 45 | ViCommand: cursorBlinkingBlock, 46 | ViOpp: cursorBlinkingUnderline, 47 | Visual: cursorBlock, 48 | Emacs: cursorBlinkingBlock, 49 | } 50 | 51 | // PrintCursor prints the cursor for the given keymap mode, 52 | // either default value or the one specified in inputrc file. 53 | func (m *Engine) PrintCursor(keymap Mode) { 54 | var cursor CursorStyle 55 | 56 | // Check for a configured cursor in .inputrc file. 57 | cursorOptname := "cursor-" + string(keymap) 58 | modeSet := strings.TrimSpace(m.config.GetString(cursorOptname)) 59 | 60 | if _, valid := cursors[CursorStyle(modeSet)]; valid { 61 | fmt.Print(cursors[CursorStyle(modeSet)]) 62 | return 63 | } 64 | 65 | if defaultCur, valid := defaultCursors[keymap]; valid { 66 | fmt.Print(cursors[defaultCur]) 67 | return 68 | } 69 | 70 | fmt.Print(cursors[cursor]) 71 | } 72 | -------------------------------------------------------------------------------- /internal/keymap/emacs.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import "github.com/reeflective/readline/inputrc" 4 | 5 | var unescape = inputrc.Unescape 6 | 7 | // emacsKeys are the default keymaps in Emacs mode. 8 | var emacsKeys = map[string]inputrc.Bind{ 9 | unescape(`\C-D`): {Action: "end-of-file"}, 10 | unescape(`\C-h`): {Action: "backward-kill-word"}, 11 | unescape(`\C-N`): {Action: "down-line-or-history"}, 12 | unescape(`\C-P`): {Action: "up-line-or-history"}, 13 | unescape(`\C-x\C-b`): {Action: "vi-match"}, 14 | unescape(`\C-x\C-e`): {Action: "edit-command-line"}, 15 | unescape(`\C-x\C-n`): {Action: "infer-next-history"}, 16 | unescape(`\C-x\C-o`): {Action: "overwrite-mode"}, 17 | unescape(`\C-Xr`): {Action: "reverse-search-history"}, 18 | unescape(`\C-Xs`): {Action: "forward-search-history"}, 19 | unescape(`\C-Xu`): {Action: "undo"}, 20 | unescape(`\M-\C-^`): {Action: "copy-prev-word"}, 21 | unescape(`\M-'`): {Action: "quote-line"}, 22 | unescape(`\M-<`): {Action: "beginning-of-buffer-or-history"}, 23 | unescape(`\M->`): {Action: "end-of-buffer-or-history"}, 24 | unescape(`\M-c`): {Action: "capitalize-word"}, 25 | unescape(`\M-d`): {Action: "kill-word"}, 26 | unescape(`\M-m`): {Action: "copy-prev-shell-word"}, 27 | unescape(`\M-n`): {Action: "history-search-forward"}, 28 | unescape(`\M-p`): {Action: "history-search-backward"}, 29 | unescape(`\M-u`): {Action: "up-case-word"}, 30 | unescape(`\M-w`): {Action: "kill-region"}, 31 | unescape(`\M-|`): {Action: "vi-goto-column"}, 32 | } 33 | -------------------------------------------------------------------------------- /internal/keymap/engine.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/reeflective/readline/inputrc" 7 | "github.com/reeflective/readline/internal/core" 8 | ) 9 | 10 | // Engine is used to manage the main and local keymaps for the shell. 11 | type Engine struct { 12 | local Mode 13 | main Mode 14 | prefixed inputrc.Bind 15 | active inputrc.Bind 16 | pending []inputrc.Bind 17 | skip bool 18 | isCaller bool 19 | nonIncSearch bool 20 | 21 | keys *core.Keys 22 | iterations *core.Iterations 23 | config *inputrc.Config 24 | commands map[string]func() 25 | } 26 | 27 | // NewEngine is a required constructor for the keymap modes manager. 28 | // It initializes the keymaps to their defaults or configured values. 29 | func NewEngine(keys *core.Keys, i *core.Iterations, opts ...inputrc.Option) (*Engine, *inputrc.Config) { 30 | modes := &Engine{ 31 | main: Emacs, 32 | keys: keys, 33 | iterations: i, 34 | config: inputrc.NewDefaultConfig(), 35 | commands: make(map[string]func()), 36 | } 37 | 38 | // Load the inputrc configurations and set up related things. 39 | modes.ReloadConfig(opts...) 40 | 41 | return modes, modes.config 42 | } 43 | 44 | // Register adds command functions to the list of available commands. 45 | // Each key of the map should be a unique name, not yet used by any 46 | // other builtin/user command, in order not to "overload" the builtins. 47 | func (m *Engine) Register(commands map[string]func()) { 48 | for name, command := range commands { 49 | m.commands[name] = command 50 | } 51 | } 52 | 53 | // SetMain sets the main keymap of the shell. 54 | // Valid builtin keymaps are: 55 | // - emacs, emacs-meta, emacs-ctlx, emacs-standard. 56 | // - vi, vi-insert, vi-command, vi-move. 57 | func (m *Engine) SetMain(keymap string) { 58 | m.main = Mode(keymap) 59 | m.UpdateCursor() 60 | } 61 | 62 | // Main returns the local keymap. 63 | func (m *Engine) Main() Mode { 64 | return m.main 65 | } 66 | 67 | // SetLocal sets the local keymap of the shell. 68 | // Valid builtin keymaps are: 69 | // - vi-opp, vi-visual. (used in commands like yank, change, delete, etc.) 70 | // - isearch, menu-select (used in search and completion). 71 | func (m *Engine) SetLocal(keymap string) { 72 | m.local = Mode(keymap) 73 | m.UpdateCursor() 74 | } 75 | 76 | // Local returns the local keymap. 77 | func (m *Engine) Local() Mode { 78 | return m.local 79 | } 80 | 81 | // ResetLocal deactivates the local keymap of the shell. 82 | func (m *Engine) ResetLocal() { 83 | m.local = "" 84 | m.UpdateCursor() 85 | } 86 | 87 | // UpdateCursor reprints the cursor corresponding to the current keymaps. 88 | func (m *Engine) UpdateCursor() { 89 | switch m.local { 90 | case ViOpp: 91 | m.PrintCursor(ViOpp) 92 | return 93 | case Visual: 94 | m.PrintCursor(Visual) 95 | return 96 | } 97 | 98 | // But if not, we check for the global keymap 99 | switch m.main { 100 | case Emacs, EmacsStandard, EmacsMeta, EmacsCtrlX: 101 | m.PrintCursor(Emacs) 102 | case ViInsert: 103 | m.PrintCursor(ViInsert) 104 | case ViCommand, ViMove, Vi: 105 | m.PrintCursor(ViCommand) 106 | } 107 | } 108 | 109 | // PendingCursor changes the cursor to pending mode, 110 | // and returns a function to call once done with it. 111 | func (m *Engine) PendingCursor() (restore func()) { 112 | m.PrintCursor(ViOpp) 113 | 114 | return func() { 115 | m.UpdateCursor() 116 | } 117 | } 118 | 119 | // IsEmacs returns true if the main keymap is one of the emacs modes. 120 | func (m *Engine) IsEmacs() bool { 121 | switch m.main { 122 | case Emacs, EmacsStandard, EmacsMeta, EmacsCtrlX: 123 | return true 124 | default: 125 | return false 126 | } 127 | } 128 | 129 | // PrintBinds displays a list of currently bound commands (and their sequences) 130 | // to the screen. If inputrcFormat is true, it displays it formatted such that 131 | // the output can be reused in an .inputrc file. 132 | func (m *Engine) PrintBinds(keymap string, inputrcFormat bool) { 133 | var commands []string 134 | 135 | for command := range m.commands { 136 | commands = append(commands, command) 137 | } 138 | 139 | sort.Strings(commands) 140 | 141 | binds := m.config.Binds[keymap] 142 | if binds == nil { 143 | return 144 | } 145 | 146 | // Make a list of all sequences bound to each command. 147 | allBinds := make(map[string][]string) 148 | 149 | for _, command := range commands { 150 | for key, bind := range binds { 151 | if bind.Action != command { 152 | continue 153 | } 154 | 155 | commandBinds := allBinds[command] 156 | commandBinds = append(commandBinds, inputrc.Escape(key)) 157 | allBinds[command] = commandBinds 158 | } 159 | } 160 | 161 | if inputrcFormat { 162 | printBindsInputrc(commands, allBinds) 163 | } else { 164 | printBindsReadable(commands, allBinds) 165 | } 166 | } 167 | 168 | // InputIsTerminator returns true when current input keys are one of 169 | // the configured or builtin "terminators", which can be configured 170 | // in .inputrc with the isearch-terminators variable. 171 | func (m *Engine) InputIsTerminator() bool { 172 | terminators := []string{ 173 | inputrc.Unescape(`\C-G`), 174 | inputrc.Unescape(`\C-]`), 175 | } 176 | 177 | binds := make(map[string]inputrc.Bind) 178 | 179 | for _, sequence := range terminators { 180 | binds[sequence] = inputrc.Bind{Action: "abort", Macro: false} 181 | } 182 | 183 | bind, _, _, _ := m.dispatchKeys(binds) 184 | 185 | return bind.Action == "abort" 186 | } 187 | 188 | // Commands returns the map of all command functions available to the shell. 189 | // This includes the builtin commands (emacs/Vim/history/completion/etc), as 190 | // well as any functions added by the user through Keymap.Register(). 191 | // The keys of this map are the names of each corresponding command function. 192 | func (m *Engine) Commands() map[string]func() { 193 | return m.commands 194 | } 195 | 196 | // ActiveCommand returns the sequence/command currently being ran. 197 | func (m *Engine) ActiveCommand() inputrc.Bind { 198 | return m.active 199 | } 200 | 201 | // NonIncrementalSearchStart is used to notify the keymap dispatchers 202 | // that are using a minibuffer, and that the set of valid commands 203 | // should be restrained to a few ones (self-insert/abort/rubout...). 204 | func (m *Engine) NonIncrementalSearchStart() { 205 | m.nonIncSearch = true 206 | } 207 | 208 | // NonIncrementalSearchStop notifies the keymap dispatchers 209 | // that we stopped editing a non-incremental search minibuffer. 210 | func (m *Engine) NonIncrementalSearchStop() { 211 | m.nonIncSearch = false 212 | } 213 | -------------------------------------------------------------------------------- /internal/keymap/mode.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | // Mode is a root keymap mode for the shell. 4 | // To each of these keymap modes is bound a keymap. 5 | type Mode string 6 | 7 | // These are the root keymaps used in the readline shell. 8 | // Their functioning is similar to how ZSH organizes keymaps. 9 | const ( 10 | // Editor. 11 | Emacs = "emacs" 12 | EmacsMeta = "emacs-meta" 13 | EmacsCtrlX = "emacs-ctlx" 14 | EmacsStandard = "emacs-standard" 15 | 16 | ViInsert = "vi-insert" 17 | Vi = "vi" 18 | ViCommand = "vi-command" 19 | ViMove = "vi-move" 20 | Visual = "vi-visual" 21 | ViOpp = "vi-opp" 22 | 23 | // Completion and search. 24 | Isearch = "isearch" 25 | MenuSelect = "menu-select" 26 | ) 27 | -------------------------------------------------------------------------------- /internal/keymap/pending.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import "github.com/reeflective/readline/inputrc" 4 | 5 | // action is represents the action of a widget, the number of times 6 | // this widget needs to be run, and an optional operator argument. 7 | // Most of the time we don't need this operator. 8 | // 9 | // Those actions are mostly used by widgets which make the shell enter 10 | // the Vim operator pending mode, and thus require another key to be read. 11 | type action struct { 12 | command inputrc.Bind 13 | } 14 | 15 | // Pending registers a command as waiting for another command to run first, 16 | // such as yank/delete/change actions, which accept/require a movement command. 17 | func (m *Engine) Pending() { 18 | m.SetLocal(ViOpp) 19 | m.skip = true 20 | 21 | // Push the widget on the stack of widgets 22 | m.pending = append(m.pending, m.active) 23 | } 24 | 25 | // CancelPending is used by commands that have been registering themselves 26 | // as waiting for a pending operator, but have actually been called twice 27 | // in a row (eg. dd/yy in Vim mode). This removes those commands from queue. 28 | func (m *Engine) CancelPending() { 29 | if len(m.pending) == 0 { 30 | return 31 | } 32 | 33 | m.pending = m.pending[:len(m.pending)-1] 34 | 35 | if len(m.pending) == 0 && m.Local() == ViOpp { 36 | m.SetLocal("") 37 | } 38 | } 39 | 40 | // IsPending returns true when invoked from within the command 41 | // that also happens to be the next in line of pending commands. 42 | func (m *Engine) IsPending() bool { 43 | if len(m.pending) == 0 { 44 | return false 45 | } 46 | 47 | return m.active.Action == m.pending[0].Action 48 | } 49 | 50 | // RunPending runs any command with pending execution. 51 | func (m *Engine) RunPending() { 52 | if len(m.pending) == 0 { 53 | return 54 | } 55 | 56 | if m.skip { 57 | m.skip = false 58 | return 59 | } 60 | 61 | defer m.UpdateCursor() 62 | 63 | // Get the last registered action. 64 | pending := m.pending[len(m.pending)-1] 65 | m.pending = m.pending[:len(m.pending)-1] 66 | 67 | // The same command might be used twice in a row (dd/yy) 68 | if pending.Action == m.active.Action { 69 | m.isCaller = true 70 | defer func() { m.isCaller = false }() 71 | } 72 | 73 | if pending.Action == "" { 74 | return 75 | } 76 | 77 | // Resolve and run the command 78 | command := m.resolve(pending) 79 | 80 | command() 81 | 82 | // And adapt the local keymap. 83 | if len(m.pending) == 0 && m.Local() == ViOpp { 84 | m.SetLocal("") 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/keymap/vim.go: -------------------------------------------------------------------------------- 1 | package keymap 2 | 3 | import "github.com/reeflective/readline/inputrc" 4 | 5 | // viinsKeys are the default keymaps in Vim Insert mode. 6 | var viinsKeys = map[string]inputrc.Bind{ 7 | unescape(`\M-`): {Action: "vi-movement-mode"}, 8 | unescape(`\C-M`): {Action: "accept-line"}, 9 | unescape(`\C-L`): {Action: "clear-screen"}, 10 | unescape(`\C-Y`): {Action: "yank"}, 11 | unescape(`\C-A`): {Action: "beginning-of-line"}, 12 | unescape(`\C-B`): {Action: "backward-char"}, 13 | unescape(`\C-F`): {Action: "forward-char"}, 14 | unescape(`\C-K`): {Action: "kill-line"}, 15 | unescape(`\C-N`): {Action: "down-line-or-history"}, 16 | unescape(`\C-O`): {Action: "operate-and-get-next"}, 17 | unescape(`\C-Q`): {Action: "accept-and-infer-next-history"}, 18 | unescape(`\C-P`): {Action: "up-line-or-history"}, 19 | unescape(`\C-_`): {Action: "undo"}, 20 | unescape(`\M-q`): {Action: "macro-toggle-record"}, 21 | unescape(`\M-r`): {Action: "vi-registers-complete"}, 22 | unescape(`\M-[3~`): {Action: "delete-char"}, 23 | unescape(`\M-[H`): {Action: "beginning-of-line"}, 24 | unescape(`\M-[F`): {Action: "end-of-line"}, 25 | unescape(`\M-[A`): {Action: "up-line-or-search"}, 26 | unescape(`\M-[B`): {Action: "down-line-or-search"}, 27 | unescape(`\M-@`): {Action: "macro-run"}, 28 | } 29 | 30 | // viinsKeymaps are the default keymaps in Vim Command mode. 31 | var vicmdKeys = map[string]inputrc.Bind{ 32 | unescape(`\M-`): {Action: "vi-movement-mode"}, 33 | unescape(`\C-A`): {Action: "switch-keyword"}, 34 | unescape(`\C-L`): {Action: "clear-screen"}, 35 | unescape(`\C-M`): {Action: "accept-line"}, 36 | unescape(`\C-N`): {Action: "next-history"}, 37 | unescape(`\C-P`): {Action: "previous-history"}, 38 | unescape(`\C-X`): {Action: "switch-keyword"}, 39 | unescape(`\M-<`): {Action: "beginning-of-buffer-or-history"}, 40 | unescape(`\M->`): {Action: "end-of-buffer-or-history"}, 41 | unescape(`\M-'`): {Action: "quote-line"}, 42 | unescape(`\e[3~`): {Action: "delete-char"}, 43 | unescape(`\e[6~`): {Action: "down-line-or-history"}, 44 | unescape(`\e[5~`): {Action: "up-line-or-history"}, 45 | unescape(`\e[H`): {Action: "beginning-of-line"}, 46 | unescape(`\e[F`): {Action: "end-of-line"}, 47 | unescape(`\e[A`): {Action: "history-search-backward"}, 48 | unescape(`\e[B`): {Action: "menu-select"}, 49 | unescape(`\e[C`): {Action: "vi-forward-char"}, 50 | unescape(`\e[D`): {Action: "vi-backward-char"}, 51 | unescape(`\e[3;5~`): {Action: "kill-word"}, 52 | unescape(`\e[1;5C`): {Action: "forward-word"}, 53 | unescape(`\e[1;5D`): {Action: "backward-word"}, 54 | unescape(" "): {Action: "vi-forward-char"}, 55 | unescape("$"): {Action: "vi-end-of-line"}, 56 | unescape("%"): {Action: "vi-match"}, 57 | unescape("\""): {Action: "vi-set-buffer"}, 58 | unescape("0"): {Action: "beginning-of-line"}, 59 | unescape("B"): {Action: "vi-backward-bigword"}, 60 | unescape("e"): {Action: "vi-end-word"}, 61 | unescape("E"): {Action: "vi-end-bigword"}, 62 | unescape("gg"): {Action: "beginning-of-buffer-or-history"}, 63 | unescape("ge"): {Action: "vi-backward-end-word"}, 64 | unescape("gE"): {Action: "vi-backward-end-bigword"}, 65 | unescape("gu"): {Action: "vi-down-case"}, 66 | unescape("gU"): {Action: "vi-up-case"}, 67 | unescape("f"): {Action: "vi-find-next-char"}, 68 | unescape("t"): {Action: "vi-find-next-char-skip"}, 69 | unescape("i"): {Action: "vi-insertion-mode"}, 70 | unescape("I"): {Action: "vi-insert-beg"}, 71 | unescape("h"): {Action: "vi-backward-char"}, 72 | unescape("l"): {Action: "vi-forward-char"}, 73 | unescape("j"): {Action: "down-line-or-history"}, 74 | unescape("k"): {Action: "up-line-or-history"}, 75 | unescape("n"): {Action: "vi-search-again"}, 76 | unescape("N"): {Action: "vi-search-again"}, 77 | unescape("O"): {Action: "vi-open-line-above"}, 78 | unescape("o"): {Action: "vi-open-line-below"}, 79 | unescape("p"): {Action: "vi-put-after"}, 80 | unescape("P"): {Action: "vi-put-before"}, 81 | unescape("q"): {Action: "macro-toggle-record"}, 82 | unescape("r"): {Action: "vi-change-char"}, 83 | unescape("R"): {Action: "vi-replace"}, 84 | unescape("F"): {Action: "vi-find-prev-char"}, 85 | unescape("T"): {Action: "vi-find-prev-char-skip"}, 86 | unescape("s"): {Action: "vi-subst"}, 87 | unescape("u"): {Action: "vi-undo"}, 88 | unescape("v"): {Action: "vi-visual-mode"}, 89 | unescape("V"): {Action: "vi-visual-line-mode"}, 90 | unescape("w"): {Action: "vi-forward-word"}, 91 | unescape("W"): {Action: "vi-forward-bigword"}, 92 | unescape("x"): {Action: "vi-delete"}, 93 | unescape("X"): {Action: "vi-backward-delete-char"}, 94 | unescape("y"): {Action: "vi-yank-to"}, 95 | unescape("Y"): {Action: "vi-yank-whole-line"}, 96 | unescape("|"): {Action: "vi-column"}, 97 | unescape("~"): {Action: "vi-change-case"}, 98 | unescape("@"): {Action: "macro-run"}, 99 | } 100 | 101 | // vioppKeys are the default keymaps in Vim Operating Pending mode. 102 | var vioppKeys = map[string]inputrc.Bind{ 103 | unescape(`\M-`): {Action: "vi-movement-mode"}, 104 | unescape("a"): {Action: "vi-select-inside"}, 105 | unescape("aW"): {Action: "select-a-blank-word"}, 106 | unescape("aa"): {Action: "select-a-shell-word"}, 107 | unescape("aw"): {Action: "select-a-word"}, 108 | unescape("i"): {Action: "vi-select-inside"}, 109 | unescape("iW"): {Action: "select-in-blank-word"}, 110 | unescape("ia"): {Action: "select-in-shell-word"}, 111 | unescape("iw"): {Action: "select-in-word"}, 112 | unescape("s"): {Action: "vi-select-surround"}, 113 | unescape("j"): {Action: "down-line"}, 114 | unescape("k"): {Action: "up-line"}, 115 | } 116 | 117 | // visualKeys are the default keymaps in Vim Visual mode. 118 | var visualKeys = map[string]inputrc.Bind{ 119 | unescape(`\M-`): {Action: "vi-movement-mode"}, 120 | unescape("aW"): {Action: "select-a-blank-word"}, 121 | unescape("aa"): {Action: "select-a-shell-word"}, 122 | unescape("aw"): {Action: "select-a-word"}, 123 | unescape("iW"): {Action: "select-in-blank-word"}, 124 | unescape("ia"): {Action: "select-in-shell-word"}, 125 | unescape("iw"): {Action: "select-in-word"}, 126 | unescape("a"): {Action: "vi-select-inside"}, 127 | unescape("c"): {Action: "vi-change-to"}, 128 | unescape("d"): {Action: "vi-delete-to"}, 129 | unescape("i"): {Action: "vi-select-inside"}, 130 | unescape("j"): {Action: "next-screen-line"}, 131 | unescape("k"): {Action: "previous-screen-line"}, 132 | unescape("s"): {Action: "vi-subst"}, 133 | unescape("S"): {Action: "vi-add-surround"}, 134 | unescape("u"): {Action: "vi-down-case"}, 135 | unescape("v"): {Action: "vi-edit-command-line"}, 136 | unescape("x"): {Action: "vi-delete-to"}, 137 | unescape("y"): {Action: "vi-yank-to"}, 138 | unescape("~"): {Action: "vi-swap-case"}, 139 | } 140 | -------------------------------------------------------------------------------- /internal/macro/engine.go: -------------------------------------------------------------------------------- 1 | package macro 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/reeflective/readline/inputrc" 9 | "github.com/reeflective/readline/internal/color" 10 | "github.com/reeflective/readline/internal/core" 11 | "github.com/reeflective/readline/internal/ui" 12 | ) 13 | 14 | // validMacroKeys - All valid macro IDs (keys) for read/write Vim registers. 15 | var validMacroKeys = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"" 16 | 17 | // Engine manages all things related to keyboard macros: 18 | // recording, dumping and feeding (running) them to the shell. 19 | type Engine struct { 20 | recording bool 21 | current []rune // Key sequence of the current macro being recorded. 22 | currentKey rune // The identifier of the macro being recorded. 23 | macros map[rune]string // All previously recorded macros. 24 | started bool 25 | 26 | keys *core.Keys // The engine feeds macros directly in the key stack. 27 | hint *ui.Hint // The engine notifies when macro recording starts/stops. 28 | status string // The hint status displaying the currently recorded macro. 29 | } 30 | 31 | // NewEngine is a required constructor to setup a working macro engine. 32 | func NewEngine(keys *core.Keys, hint *ui.Hint) *Engine { 33 | return &Engine{ 34 | current: make([]rune, 0), 35 | macros: make(map[rune]string), 36 | keys: keys, 37 | hint: hint, 38 | } 39 | } 40 | 41 | // RecordKeys is being passed every key read by the shell, and will save 42 | // those entered while the engine is in record mode. All others are ignored. 43 | func RecordKeys(eng *Engine) { 44 | if !eng.recording { 45 | return 46 | } 47 | 48 | keys := core.MacroKeys(eng.keys) 49 | if len(keys) == 0 { 50 | return 51 | } 52 | 53 | // The first call to record should not add 54 | // the caller keys that started the recording. 55 | if !eng.started { 56 | eng.current = append(eng.current, keys...) 57 | } 58 | 59 | eng.started = false 60 | 61 | eng.hint.Persist(eng.status + inputrc.EscapeMacro(string(eng.current)) + color.Reset) 62 | } 63 | 64 | // StartRecord starts saving all user key input to record a macro. 65 | // If the key parameter is an alphanumeric character, the macro recorded will be 66 | // stored and used through this letter argument, just like macros work in Vim. 67 | // If the key is neither valid nor the null value, the engine does not start. 68 | // A notification containing the saved sequence is given through the hint section. 69 | func (e *Engine) StartRecord(key rune) { 70 | switch { 71 | case isValidMacroID(key), key == 0: 72 | e.currentKey = key 73 | default: 74 | return 75 | } 76 | 77 | e.started = true 78 | e.recording = true 79 | e.status = color.Dim + "Recording macro: " + color.Bold 80 | e.hint.Persist(e.status) 81 | } 82 | 83 | // StopRecord stops using key input as part of a macro. 84 | // The hint section displaying the currently saved sequence is cleared. 85 | func (e *Engine) StopRecord(keys ...rune) { 86 | e.recording = false 87 | 88 | // Remove the hint. 89 | e.hint.ResetPersist() 90 | 91 | if len(e.current) == 0 { 92 | return 93 | } 94 | 95 | e.current = append(e.current, keys...) 96 | macro := inputrc.EscapeMacro(string(e.current)) 97 | 98 | e.macros[e.currentKey] = macro 99 | e.macros[rune(0)] = macro 100 | 101 | e.current = make([]rune, 0) 102 | } 103 | 104 | // Recording returns true if the macro engine is recording the keys for a macro. 105 | func (e *Engine) Recording() bool { 106 | return e.recording 107 | } 108 | 109 | // RunLastMacro feeds keys the last recorded macro to the shell's key stack, 110 | // so that the macro is replayed. 111 | // Note that this function only feeds the keys of the macro back into the key 112 | // stack: it does not dispatch them to commands, therefore not running any. 113 | func (e *Engine) RunLastMacro() { 114 | if len(e.macros) == 0 { 115 | return 116 | } 117 | 118 | macro := inputrc.Unescape(e.macros[rune(0)]) 119 | 120 | if len(macro) == 0 { 121 | return 122 | } 123 | 124 | e.keys.Feed(false, []rune(macro)...) 125 | } 126 | 127 | // RunMacro runs a given macro, injecting its key sequence back into the shell key stack. 128 | // The key argument should either be one of the valid alphanumeric macro identifiers, or 129 | // a nil rune (in which case the last recorded macro is ran). 130 | // Note that this function only feeds the keys of the macro back into the key 131 | // stack: it does not dispatch them to commands, therefore not running any. 132 | func (e *Engine) RunMacro(key rune) { 133 | if !isValidMacroID(key) && key != 0 { 134 | return 135 | } 136 | 137 | macro := e.macros[key] 138 | if len(macro) == 0 { 139 | return 140 | } 141 | 142 | macro = strings.ReplaceAll(macro, `\e`, "\x1b") 143 | e.keys.Feed(false, []rune(macro)...) 144 | } 145 | 146 | // PrintLastMacro dumps the last recorded macro sequence to the screen. 147 | func (e *Engine) PrintLastMacro() { 148 | if len(e.macros) == 0 { 149 | return 150 | } 151 | 152 | // Print the macro and the prompt. 153 | // The shell takes care of clearing itself 154 | // before printing, and refreshing after. 155 | fmt.Printf("\n%s\n", e.macros[e.currentKey]) 156 | } 157 | 158 | // PrintAllMacros dumps all macros to the screen, which one line 159 | // per saved macro sequence, next to its corresponding key ID. 160 | func (e *Engine) PrintAllMacros() { 161 | var macroIDs []rune 162 | 163 | for key := range e.macros { 164 | macroIDs = append(macroIDs, key) 165 | } 166 | 167 | sort.Slice(macroIDs, func(i, j int) bool { 168 | return macroIDs[i] < macroIDs[j] 169 | }) 170 | 171 | for _, macro := range macroIDs { 172 | sequence := e.macros[macro] 173 | if sequence == "" { 174 | continue 175 | } 176 | 177 | if macro == 0 { 178 | macro = '"' 179 | } 180 | 181 | fmt.Printf("\"%s\": %s\n", string(macro), sequence) 182 | } 183 | } 184 | 185 | func isValidMacroID(key rune) bool { 186 | for _, char := range validMacroKeys { 187 | if char == key { 188 | return true 189 | } 190 | } 191 | 192 | return false 193 | } 194 | -------------------------------------------------------------------------------- /internal/strutil/key.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import "github.com/reeflective/readline/inputrc" 4 | 5 | // ConvertMeta recursively searches for metafied keys in a sequence, 6 | // and replaces them with an esc prefix and their unmeta equivalent. 7 | func ConvertMeta(keys []rune) string { 8 | if len(keys) == 0 { 9 | return string(keys) 10 | } 11 | 12 | converted := make([]rune, 0) 13 | 14 | for i := 0; i < len(keys); i++ { 15 | char := keys[i] 16 | 17 | if !inputrc.IsMeta(char) { 18 | converted = append(converted, char) 19 | continue 20 | } 21 | 22 | // Replace the key with esc prefix and add the demetafied key. 23 | converted = append(converted, inputrc.Esc) 24 | converted = append(converted, inputrc.Demeta(char)) 25 | } 26 | 27 | return string(converted) 28 | } 29 | 30 | // Quote translates one rune in its printable version, 31 | // which might be different for Control/Meta characters. 32 | // Returns the "translated" string and new length. (eg 0x04 => ^C = len:2). 33 | func Quote(char rune) (res []rune, length int) { 34 | var inserted []rune 35 | 36 | // Special cases for keys that should not be quoted 37 | if char == inputrc.Tab { 38 | inserted = append(inserted, char) 39 | return inserted, len(inserted) 40 | } 41 | 42 | switch { 43 | case inputrc.IsMeta(char): 44 | inserted = append(inserted, '^', '[') 45 | inserted = append(inserted, inputrc.Demeta(char)) 46 | case inputrc.IsControl(char): 47 | inserted = append(inserted, '^') 48 | inserted = append(inserted, inputrc.Decontrol(char)) 49 | default: 50 | inserted = []rune(inputrc.Unescape(string(char))) 51 | } 52 | 53 | return inserted, len(inserted) 54 | } 55 | -------------------------------------------------------------------------------- /internal/strutil/len.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/rivo/uniseg" 7 | 8 | "github.com/reeflective/readline/internal/color" 9 | "github.com/reeflective/readline/internal/term" 10 | ) 11 | 12 | // FormatTabs replaces all '\t' occurrences in a string with 6 spaces each. 13 | func FormatTabs(s string) string { 14 | return strings.ReplaceAll(s, "\t", " ") 15 | } 16 | 17 | // RealLength returns the real length of a string (the number of terminal 18 | // columns used to render the line, which may contain special graphemes). 19 | // Before computing the width, it replaces tabs with (4) spaces, and strips colors. 20 | func RealLength(s string) int { 21 | colors := color.Strip(s) 22 | tabs := strings.ReplaceAll(colors, "\t", " ") 23 | 24 | return uniseg.StringWidth(tabs) 25 | } 26 | 27 | // LineSpan computes the number of columns and lines that are needed for a given line, 28 | // accounting for any ANSI escapes/color codes, and tabulations replaced with 4 spaces. 29 | func LineSpan(line []rune, idx, indent int) (x, y int) { 30 | termWidth := term.GetWidth() 31 | lineLen := RealLength(string(line)) 32 | lineLen += indent 33 | 34 | cursorY := lineLen / termWidth 35 | cursorX := lineLen % termWidth 36 | 37 | // Empty lines are still considered a line. 38 | if idx != 0 { 39 | cursorY++ 40 | } 41 | 42 | return cursorX, cursorY 43 | } 44 | -------------------------------------------------------------------------------- /internal/strutil/split.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "regexp" 7 | "strings" 8 | "unicode/utf8" 9 | ) 10 | 11 | var ( 12 | errUnterminatedSingleQuote = errors.New("unterminated single-quoted string") 13 | errUnterminatedDoubleQuote = errors.New("unterminated double-quoted string") 14 | errUnterminatedEscape = errors.New("unterminated backslash-escape") 15 | ) 16 | 17 | var ( 18 | splitChars = " \n\t" 19 | singleChar = '\'' 20 | doubleChar = '"' 21 | escapeChar = '\\' 22 | doubleEscapeChars = "$`\"\n\\" 23 | ) 24 | 25 | // NewlineMatcher is a regular expression matching all newlines or returned newlines. 26 | var NewlineMatcher = regexp.MustCompile(`\r\n`) 27 | 28 | // Split splits a string according to /bin/sh's word-splitting rules. It 29 | // supports backslash-escapes, single-quotes, and double-quotes. Notably it does 30 | // not support the $” style of quoting. It also doesn't attempt to perform any 31 | // other sort of expansion, including brace expansion, shell expansion, or 32 | // pathname expansion. 33 | // 34 | // If the given input has an unterminated quoted string or ends in a 35 | // backslash-escape, one of UnterminatedSingleQuoteError, 36 | // UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned. 37 | func Split(input string) (words []string, err error) { 38 | var buf bytes.Buffer 39 | words = make([]string, 0) 40 | 41 | for len(input) > 0 { 42 | // skip any splitChars at the start 43 | c, l := utf8.DecodeRuneInString(input) 44 | if strings.ContainsRune(splitChars, c) { 45 | input = input[l:] 46 | continue 47 | } else if c == escapeChar { 48 | // Look ahead for escaped newline so we can skip over it 49 | next := input[l:] 50 | if len(next) == 0 { 51 | err = errUnterminatedEscape 52 | return 53 | } 54 | c2, l2 := utf8.DecodeRuneInString(next) 55 | if c2 == '\n' { 56 | input = next[l2:] 57 | continue 58 | } 59 | } 60 | 61 | var word string 62 | word, input, err = splitWord(input, &buf) 63 | if err != nil { 64 | return 65 | } 66 | words = append(words, word) 67 | } 68 | return 69 | } 70 | 71 | func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { 72 | buf.Reset() 73 | 74 | raw: 75 | { 76 | cur := input 77 | for len(cur) > 0 { 78 | c, l := utf8.DecodeRuneInString(cur) 79 | cur = cur[l:] 80 | if c == singleChar { 81 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 82 | input = cur 83 | goto single 84 | } else if c == doubleChar { 85 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 86 | input = cur 87 | goto double 88 | } else if c == escapeChar { 89 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 90 | input = cur 91 | goto escape 92 | } else if strings.ContainsRune(splitChars, c) { 93 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 94 | return buf.String(), cur, nil 95 | } 96 | } 97 | if len(input) > 0 { 98 | buf.WriteString(input) 99 | input = "" 100 | } 101 | goto done 102 | } 103 | 104 | escape: 105 | { 106 | if len(input) == 0 { 107 | return "", "", errUnterminatedEscape 108 | } 109 | c, l := utf8.DecodeRuneInString(input) 110 | if c == '\n' { 111 | // a backslash-escaped newline is elided from the output entirely 112 | } else { 113 | buf.WriteString(input[:l]) 114 | } 115 | input = input[l:] 116 | } 117 | goto raw 118 | 119 | single: 120 | { 121 | i := strings.IndexRune(input, singleChar) 122 | if i == -1 { 123 | return "", "", errUnterminatedSingleQuote 124 | } 125 | buf.WriteString(input[0:i]) 126 | input = input[i+1:] 127 | goto raw 128 | } 129 | 130 | double: 131 | { 132 | cur := input 133 | for len(cur) > 0 { 134 | c, l := utf8.DecodeRuneInString(cur) 135 | cur = cur[l:] 136 | if c == doubleChar { 137 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 138 | input = cur 139 | goto raw 140 | } else if c == escapeChar { 141 | // bash only supports certain escapes in double-quoted strings 142 | c2, l2 := utf8.DecodeRuneInString(cur) 143 | cur = cur[l2:] 144 | if strings.ContainsRune(doubleEscapeChars, c2) { 145 | buf.WriteString(input[0 : len(input)-len(cur)-l-l2]) 146 | if c2 == '\n' { 147 | // newline is special, skip the backslash entirely 148 | } else { 149 | buf.WriteRune(c2) 150 | } 151 | input = cur 152 | } 153 | } 154 | } 155 | return "", "", errUnterminatedDoubleQuote 156 | } 157 | 158 | done: 159 | return buf.String(), input, nil 160 | } 161 | -------------------------------------------------------------------------------- /internal/strutil/surround.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | // MatchSurround returns the matching character of a rune that 4 | // is either a bracket/brace/parenthesis, or a single/double quote. 5 | func MatchSurround(r rune) (bchar, echar rune) { 6 | bchar = r 7 | echar = r 8 | 9 | switch bchar { 10 | case '{': 11 | echar = '}' 12 | case '(': 13 | echar = ')' 14 | case '[': 15 | echar = ']' 16 | case '<': 17 | echar = '>' 18 | case '}': 19 | bchar = '{' 20 | echar = '}' 21 | case ')': 22 | bchar = '(' 23 | echar = ')' 24 | case ']': 25 | bchar = '[' 26 | echar = ']' 27 | case '>': 28 | bchar = '<' 29 | echar = '>' 30 | case '"': 31 | bchar = '"' 32 | echar = '"' 33 | case '\'': 34 | bchar = '\'' 35 | echar = '\'' 36 | } 37 | 38 | return bchar, echar 39 | } 40 | 41 | // IsSurround returns true if the character is a quote or a bracket/brace, etc. 42 | func IsSurround(bchar, echar rune) bool { 43 | switch bchar { 44 | case '{': 45 | return echar == '}' 46 | case '(': 47 | return echar == ')' 48 | case '[': 49 | return echar == ']' 50 | case '<': 51 | return echar == '>' 52 | case '"': 53 | return echar == '"' 54 | case '\'': 55 | return echar == '\'' 56 | } 57 | 58 | return echar == bchar 59 | } 60 | 61 | // SurroundType says if the character is a pairing one (first boolean), 62 | // and if the character is the closing part of the pair (second boolean). 63 | func SurroundType(char rune) (surround, closer bool) { 64 | switch char { 65 | case '{': 66 | return true, false 67 | case '}': 68 | return true, true 69 | case '(': 70 | return true, false 71 | case ')': 72 | return true, true 73 | case '[': 74 | return true, false 75 | case ']': 76 | return true, true 77 | case '<': 78 | return true, false 79 | case '>': 80 | case '"': 81 | return true, true 82 | case '\'': 83 | return true, true 84 | } 85 | 86 | return false, false 87 | } 88 | 89 | // AdjustSurroundQuotes returns the correct mark and cursor positions when 90 | // we want to know where a shell word enclosed with quotes (and potentially 91 | // having inner ones) starts and ends. 92 | func AdjustSurroundQuotes(dBpos, dEpos, sBpos, sEpos int) (mark, cpos int) { 93 | mark = -1 94 | cpos = -1 95 | 96 | if (sBpos == -1 || sEpos == -1) && (dBpos == -1 || dEpos == -1) { 97 | return 98 | } 99 | 100 | doubleFirstAndValid := (dBpos < sBpos && // Outermost 101 | dBpos >= 0 && // Double found 102 | sBpos >= 0 && // compared with a found single 103 | dEpos > sEpos) // ensuring that we are not comparing unfound 104 | 105 | singleFirstAndValid := (sBpos < dBpos && 106 | sBpos >= 0 && 107 | dBpos >= 0 && 108 | sEpos > dEpos) 109 | 110 | if (sBpos == -1 || sEpos == -1) || doubleFirstAndValid { 111 | mark = dBpos 112 | cpos = dEpos 113 | } else if (dBpos == -1 || dEpos == -1) || singleFirstAndValid { 114 | mark = sBpos 115 | cpos = sEpos 116 | } 117 | 118 | return 119 | } 120 | 121 | // IsBracket returns true if the character is an opening/closing bracket/brace/parenthesis. 122 | func IsBracket(char rune) bool { 123 | if char == '(' || 124 | char == ')' || 125 | char == '{' || 126 | char == '}' || 127 | char == '[' || 128 | char == ']' { 129 | return true 130 | } 131 | 132 | return false 133 | } 134 | 135 | // GetQuotedWordStart returns the position of the outmost containing quote 136 | // of the word (going backward from the end of the provided line), if the 137 | // current word is a shell word that is not closed yet. 138 | // Ex: `this 'quote contains "surrounded" words`. the outermost quote is the single one. 139 | func GetQuotedWordStart(line []rune) (unclosed bool, pos int) { 140 | var ( 141 | single, double bool 142 | spos, dpos = -1, -1 143 | ) 144 | 145 | for pos, char := range line { 146 | switch char { 147 | case '\'': 148 | single = !single 149 | spos = pos 150 | case '"': 151 | double = !double 152 | dpos = pos 153 | default: 154 | continue 155 | } 156 | } 157 | 158 | if single && double { 159 | unclosed = true 160 | 161 | if spos < dpos { 162 | pos = spos 163 | } else { 164 | pos = dpos 165 | } 166 | 167 | return 168 | } 169 | 170 | if single { 171 | unclosed = true 172 | pos = spos 173 | } else if double { 174 | unclosed = true 175 | pos = dpos 176 | } 177 | 178 | return unclosed, pos 179 | } 180 | -------------------------------------------------------------------------------- /internal/term/codes.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | // Terminal control sequences. 4 | const ( 5 | NewlineReturn = "\r\n" 6 | 7 | ClearLineAfter = "\x1b[0K" 8 | ClearLineBefore = "\x1b[1K" 9 | ClearLine = "\x1b[2K" 10 | ClearScreenBelow = "\x1b[0J" 11 | ClearScreen = "\x1b[2J" // Clears screen, preserving scroll buffer 12 | ClearDisplay = "\x1b[3J" // Clears screen fully, wipes the scroll buffer 13 | 14 | CursorTopLeft = "\x1b[H" 15 | SaveCursorPos = "\x1b7" 16 | RestoreCursorPos = "\x1b8" 17 | HideCursor = "\x1b[?25l" 18 | ShowCursor = "\x1b[?25h" 19 | ) 20 | 21 | // Some core keys needed by some stuff. 22 | var ( 23 | ArrowUp = string([]byte{27, 91, 65}) // ^[[A 24 | ArrowDown = string([]byte{27, 91, 66}) // ^[[B 25 | ArrowRight = string([]byte{27, 91, 67}) // ^[[C 26 | ArrowLeft = string([]byte{27, 91, 68}) // ^[[D 27 | ) 28 | -------------------------------------------------------------------------------- /internal/term/cursor.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | // MoveCursorUp moves the cursor up i lines. 4 | func MoveCursorUp(i int) { 5 | if i < 1 { 6 | return 7 | } 8 | 9 | printf("\x1b[%dA", i) 10 | } 11 | 12 | // MoveCursorDown moves the cursor down i lines. 13 | func MoveCursorDown(i int) { 14 | if i < 1 { 15 | return 16 | } 17 | 18 | printf("\x1b[%dB", i) 19 | } 20 | 21 | // MoveCursorForwards moves the cursor forward i columns. 22 | func MoveCursorForwards(i int) { 23 | if i < 1 { 24 | return 25 | } 26 | 27 | printf("\x1b[%dC", i) 28 | } 29 | 30 | // MoveCursorBackwards moves the cursor backward i columns. 31 | func MoveCursorBackwards(i int) { 32 | if i < 1 { 33 | return 34 | } 35 | 36 | printf("\x1b[%dD", i) 37 | } 38 | -------------------------------------------------------------------------------- /internal/term/raw_bsd.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build darwin || dragonfly || freebsd || netbsd || openbsd 6 | // +build darwin dragonfly freebsd netbsd openbsd 7 | 8 | package term 9 | 10 | import "golang.org/x/sys/unix" 11 | 12 | const ( 13 | ioctlReadTermios = unix.TIOCGETA 14 | ioctlWriteTermios = unix.TIOCSETA 15 | ) 16 | 17 | // const OXTABS = unix.OXTABS 18 | -------------------------------------------------------------------------------- /internal/term/raw_linux.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build linux 6 | // +build linux 7 | 8 | package term 9 | 10 | import "golang.org/x/sys/unix" 11 | 12 | const ( 13 | ioctlReadTermios = unix.TCGETS 14 | ioctlWriteTermios = unix.TCSETS 15 | ) 16 | -------------------------------------------------------------------------------- /internal/term/raw_plan9.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package term provides support functions for dealing with terminals, 6 | // as commonly found on UNIX systems. 7 | // 8 | // Putting a terminal into raw mode is the most common requirement: 9 | // 10 | // oldState, err := terminal.MakeRaw(0) 11 | // if err != nil { 12 | // panic(err) 13 | // } 14 | // defer terminal.Restore(0, oldState) 15 | package term 16 | 17 | import ( 18 | "fmt" 19 | "runtime" 20 | ) 21 | 22 | // State contains the state of a terminal. 23 | type State struct{} 24 | 25 | // IsTerminal returns true if the given file descriptor is a terminal. 26 | func IsTerminal(fd int) bool { 27 | return false 28 | } 29 | 30 | // MakeRaw put the terminal connected to the given file descriptor into raw 31 | // mode and returns the previous state of the terminal so that it can be 32 | // restored. 33 | func MakeRaw(fd int) (*State, error) { 34 | return nil, fmt.Errorf("terminal: MakeRaw not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) 35 | } 36 | 37 | // GetState returns the current state of a terminal which may be useful to 38 | // restore the terminal after a signal. 39 | func GetState(fd int) (*State, error) { 40 | return nil, fmt.Errorf("terminal: GetState not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) 41 | } 42 | 43 | // Restore restores the terminal connected to the given file descriptor to a 44 | // previous state. 45 | func Restore(fd int, state *State) error { 46 | return fmt.Errorf("terminal: Restore not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) 47 | } 48 | 49 | // GetSize returns the dimensions of the given terminal. 50 | func GetSize(fd int) (width, height int, err error) { 51 | return 0, 0, fmt.Errorf("terminal: GetSize not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) 52 | } 53 | -------------------------------------------------------------------------------- /internal/term/raw_solaris.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build solaris || os400 || aix 6 | // +build solaris os400 aix 7 | 8 | package term 9 | 10 | import ( 11 | "syscall" 12 | 13 | "golang.org/x/sys/unix" 14 | ) 15 | 16 | // State contains the state of a terminal. 17 | type State struct { 18 | state *unix.Termios 19 | } 20 | 21 | // IsTerminal returns true if the given file descriptor is a terminal. 22 | func IsTerminal(fd int) bool { 23 | _, err := unix.IoctlGetTermio(fd, unix.TCGETA) 24 | return err == nil 25 | } 26 | 27 | // MakeRaw puts the terminal connected to the given file descriptor into raw 28 | // mode and returns the previous state of the terminal so that it can be 29 | // restored. 30 | // see http://cr.illumos.org/~webrev/andy_js/1060/ 31 | func MakeRaw(fd int) (*State, error) { 32 | oldTermiosPtr, err := unix.IoctlGetTermios(fd, unix.TCGETS) 33 | if err != nil { 34 | return nil, err 35 | } 36 | oldTermios := *oldTermiosPtr 37 | 38 | newTermios := oldTermios 39 | newTermios.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON 40 | // newTermios.Oflag &^= syscall.OPOST 41 | newTermios.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN 42 | newTermios.Cflag &^= syscall.CSIZE | syscall.PARENB 43 | newTermios.Cflag |= syscall.CS8 44 | newTermios.Cc[unix.VMIN] = 1 45 | newTermios.Cc[unix.VTIME] = 0 46 | 47 | if err := unix.IoctlSetTermios(fd, unix.TCSETS, &newTermios); err != nil { 48 | return nil, err 49 | } 50 | 51 | return &State{ 52 | state: oldTermiosPtr, 53 | }, nil 54 | } 55 | 56 | // Restore restores the terminal connected to the given file descriptor to a 57 | // previous state. 58 | func Restore(fd int, oldState *State) error { 59 | return unix.IoctlSetTermios(fd, unix.TCSETS, oldState.state) 60 | } 61 | 62 | // GetState returns the current state of a terminal which may be useful to 63 | // restore the terminal after a signal. 64 | func GetState(fd int) (*State, error) { 65 | oldTermiosPtr, err := unix.IoctlGetTermios(fd, unix.TCGETS) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return &State{ 71 | state: oldTermiosPtr, 72 | }, nil 73 | } 74 | 75 | // GetSize returns the dimensions of the given terminal. 76 | func GetSize(fd int) (width, height int, err error) { 77 | ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) 78 | if err != nil { 79 | return 0, 0, err 80 | } 81 | return int(ws.Col), int(ws.Row), nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/term/raw_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright 2011 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build darwin || dragonfly || freebsd || (linux && !appengine) || netbsd || openbsd 6 | // +build darwin dragonfly freebsd linux,!appengine netbsd openbsd 7 | 8 | package term 9 | 10 | import ( 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | // State contains the state of a terminal. 15 | type State struct { 16 | termios unix.Termios 17 | } 18 | 19 | // IsTerminal returns true if the given file descriptor is a terminal. 20 | func IsTerminal(fd int) bool { 21 | _, err := unix.IoctlGetTermios(fd, ioctlReadTermios) 22 | 23 | return err == nil 24 | } 25 | 26 | // MakeRaw put the terminal connected to the given file descriptor into raw 27 | // mode and returns the previous state of the terminal so that it can be 28 | // restored. 29 | func MakeRaw(fd int) (*State, error) { 30 | termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | oldState := State{termios: *termios} 36 | 37 | // This attempts to replicate the behaviour documented for cfmakeraw in 38 | // the termios(3) manpage. 39 | termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON 40 | 41 | termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN 42 | termios.Cflag &^= unix.CSIZE | unix.PARENB 43 | termios.Cflag |= unix.CS8 44 | termios.Cc[unix.VMIN] = 1 45 | termios.Cc[unix.VTIME] = 0 46 | 47 | if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, termios); err != nil { 48 | return nil, err 49 | } 50 | 51 | return &oldState, nil 52 | } 53 | 54 | // GetState returns the current state of a terminal which may be useful to 55 | // restore the terminal after a signal. 56 | func GetState(fd int) (*State, error) { 57 | termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return &State{termios: *termios}, nil 63 | } 64 | 65 | // Restore restores the terminal connected to the given file descriptor to a 66 | // previous state. 67 | func Restore(fd int, state *State) error { 68 | return unix.IoctlSetTermios(fd, ioctlWriteTermios, &state.termios) 69 | } 70 | 71 | // GetSize returns the dimensions of the given terminal. 72 | func GetSize(fd int) (width, height int, err error) { 73 | ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) 74 | if err != nil { 75 | return -1, -1, err 76 | } 77 | 78 | return int(ws.Col), int(ws.Row), nil 79 | } 80 | -------------------------------------------------------------------------------- /internal/term/raw_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2011 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build windows 6 | // +build windows 7 | 8 | // Package term provides support functions for dealing with terminals, 9 | // as commonly found on UNIX systems. 10 | // 11 | // Putting a terminal into raw mode is the most common requirement: 12 | // 13 | // oldState, err := terminal.MakeRaw(0) 14 | // if err != nil { 15 | // panic(err) 16 | // } 17 | // defer terminal.Restore(0, oldState) 18 | package term 19 | 20 | import ( 21 | "golang.org/x/sys/windows" 22 | ) 23 | 24 | // State contains the state of a terminal. 25 | type State struct { 26 | mode uint32 27 | } 28 | 29 | // IsTerminal returns true if the given file descriptor is a terminal. 30 | func IsTerminal(fd int) bool { 31 | var st uint32 32 | err := windows.GetConsoleMode(windows.Handle(fd), &st) 33 | return err == nil 34 | } 35 | 36 | // MakeRaw put the terminal connected to the given file descriptor into raw 37 | // mode and returns the previous state of the terminal so that it can be 38 | // restored. 39 | func MakeRaw(fd int) (*State, error) { 40 | var st uint32 41 | if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { 42 | return nil, err 43 | } 44 | raw := st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT) 45 | if err := windows.SetConsoleMode(windows.Handle(fd), raw); err != nil { 46 | return nil, err 47 | } 48 | return &State{st}, nil 49 | } 50 | 51 | // GetState returns the current state of a terminal which may be useful to 52 | // restore the terminal after a signal. 53 | func GetState(fd int) (*State, error) { 54 | var st uint32 55 | if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { 56 | return nil, err 57 | } 58 | return &State{st}, nil 59 | } 60 | 61 | // Restore restores the terminal connected to the given file descriptor to a 62 | // previous state. 63 | func Restore(fd int, state *State) error { 64 | return windows.SetConsoleMode(windows.Handle(fd), state.mode) 65 | } 66 | 67 | // GetSize returns the dimensions of the given terminal. 68 | func GetSize(fd int) (width, height int, err error) { 69 | var info windows.ConsoleScreenBufferInfo 70 | if err := windows.GetConsoleScreenBufferInfo(windows.Handle(fd), &info); err != nil { 71 | return 0, 0, err 72 | } 73 | 74 | width = int(info.Size.X) 75 | height = int(info.Size.Y) - 1 // Needs an adjustment 76 | 77 | return width, height, nil 78 | } 79 | -------------------------------------------------------------------------------- /internal/term/term.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // Those variables are very important to realine low-level code: all virtual terminal 9 | // escape sequences should always be sent and read through the raw terminal file, even 10 | // if people start using io.MultiWriters and os.Pipes involving basic IO. 11 | var ( 12 | stdoutTerm *os.File 13 | stdinTerm *os.File 14 | stderrTerm *os.File 15 | ) 16 | 17 | func init() { 18 | stdoutTerm = os.Stdout 19 | stdoutTerm = os.Stderr 20 | stderrTerm = os.Stdin 21 | } 22 | 23 | // fallback terminal width when we can't get it through query. 24 | var defaultTermWidth = 80 25 | 26 | // GetWidth returns the width of Stdout or 80 if the width cannot be established. 27 | func GetWidth() (termWidth int) { 28 | var err error 29 | fd := int(stdoutTerm.Fd()) 30 | termWidth, _, err = GetSize(fd) 31 | 32 | if err != nil || termWidth == 0 { 33 | termWidth = defaultTermWidth 34 | } 35 | 36 | return 37 | } 38 | 39 | // GetLength returns the length of the terminal 40 | // (Y length), or 80 if it cannot be established. 41 | func GetLength() int { 42 | termFd := int(stdoutTerm.Fd()) 43 | 44 | _, length, err := GetSize(termFd) 45 | if err != nil || length == 0 { 46 | return defaultTermWidth 47 | } 48 | 49 | return length 50 | } 51 | 52 | func printf(format string, a ...interface{}) { 53 | s := fmt.Sprintf(format, a...) 54 | fmt.Print(s) 55 | } 56 | -------------------------------------------------------------------------------- /internal/ui/hint.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/reeflective/readline/internal/color" 8 | "github.com/reeflective/readline/internal/strutil" 9 | "github.com/reeflective/readline/internal/term" 10 | ) 11 | 12 | // Hint is in charge of printing the usage messages below the input line. 13 | // Various other UI components have access to it so that they can feed 14 | // specialized usage messages to it, like completions. 15 | type Hint struct { 16 | text []rune 17 | persistent []rune 18 | cleanup bool 19 | temp bool 20 | set bool 21 | } 22 | 23 | // Set sets the hint message to the given text. 24 | // Generally, this hint message will persist until either a command 25 | // or the completion system overwrites it, or if hint.Reset() is called. 26 | func (h *Hint) Set(hint string) { 27 | h.text = []rune(hint) 28 | h.set = true 29 | } 30 | 31 | // SetTemporary sets a hint message that will be cleared at the next keypress 32 | // or command being run, which generally coincides with the next redisplay. 33 | func (h *Hint) SetTemporary(hint string) { 34 | h.text = []rune(hint) 35 | h.set = true 36 | h.temp = true 37 | } 38 | 39 | // Persist adds a hint message to be persistently 40 | // displayed until hint.ResetPersist() is called. 41 | func (h *Hint) Persist(hint string) { 42 | h.persistent = []rune(hint) 43 | } 44 | 45 | // Text returns the current hint text. 46 | func (h *Hint) Text() string { 47 | return string(h.text) 48 | } 49 | 50 | // Len returns the length of the current hint. 51 | // This is generally used by consumers to know if there already 52 | // is an active hint, in which case they might want to append to 53 | // it instead of overwriting it altogether (like in isearch mode). 54 | func (h *Hint) Len() int { 55 | return len(h.text) 56 | } 57 | 58 | // Reset removes the hint message. 59 | func (h *Hint) Reset() { 60 | h.text = make([]rune, 0) 61 | h.temp = false 62 | h.set = false 63 | } 64 | 65 | // ResetPersist drops the persistent hint section. 66 | func (h *Hint) ResetPersist() { 67 | h.cleanup = len(h.persistent) > 0 68 | h.persistent = make([]rune, 0) 69 | } 70 | 71 | // DisplayHint prints the hint (persistent and/or temporary) sections. 72 | func DisplayHint(hint *Hint) { 73 | if hint.temp && hint.set { 74 | hint.set = false 75 | } else if hint.temp { 76 | hint.Reset() 77 | } 78 | 79 | if len(hint.text) == 0 && len(hint.persistent) == 0 { 80 | if hint.cleanup { 81 | fmt.Print(term.ClearLineAfter) 82 | } 83 | 84 | hint.cleanup = false 85 | 86 | return 87 | } 88 | 89 | text := hint.renderHint() 90 | 91 | if strutil.RealLength(text) == 0 { 92 | return 93 | } 94 | 95 | text += term.ClearLineAfter + color.Reset 96 | 97 | if len(text) > 0 { 98 | fmt.Print(text) 99 | } 100 | } 101 | 102 | func (h *Hint) renderHint() (text string) { 103 | if len(h.persistent) > 0 { 104 | text += string(h.persistent) + term.NewlineReturn 105 | } 106 | 107 | if len(h.text) > 0 { 108 | text += string(h.text) + term.NewlineReturn 109 | } 110 | 111 | if strutil.RealLength(text) == 0 { 112 | return 113 | } 114 | 115 | // Ensure cross-platform, real display newline. 116 | text = strings.ReplaceAll(text, term.NewlineReturn, term.ClearLineAfter+term.NewlineReturn) 117 | 118 | return text 119 | } 120 | 121 | // CoordinatesHint returns the number of terminal rows used by the hint. 122 | func CoordinatesHint(hint *Hint) int { 123 | text := hint.renderHint() 124 | 125 | // Nothing to do if no real text 126 | text = strings.TrimSuffix(text, term.ClearLineAfter+term.NewlineReturn) 127 | 128 | if strutil.RealLength(text) == 0 { 129 | return 0 130 | } 131 | 132 | // Otherwise compute the real length/span. 133 | usedY := 0 134 | lines := strings.Split(text, term.ClearLineAfter) 135 | 136 | for i, line := range lines { 137 | x, y := strutil.LineSpan([]rune(line), i, 0) 138 | if x != 0 { 139 | y++ 140 | } 141 | 142 | usedY += y 143 | } 144 | 145 | return usedY 146 | } 147 | -------------------------------------------------------------------------------- /readline.go: -------------------------------------------------------------------------------- 1 | // Package readline provides a modern, pure Go `readline` shell implementation, 2 | // with full `.inputrc` and legacy readline command/option support, and extended 3 | // with various commands,options and tools commonly found in more modern shells. 4 | // 5 | // Example usage: 6 | // 7 | // // Create a new shell with a custom prompt. 8 | // rl := readline.NewShell() 9 | // rl.Prompt.Primary(func() string { return "> "} ) 10 | // 11 | // // Display the prompt, read user input. 12 | // for { 13 | // line, err := rl.Readline() 14 | // if err != nil { 15 | // break 16 | // } 17 | // fmt.Println(line) 18 | // } 19 | package readline 20 | 21 | import ( 22 | "errors" 23 | "fmt" 24 | "os" 25 | 26 | "github.com/reeflective/readline/inputrc" 27 | "github.com/reeflective/readline/internal/color" 28 | "github.com/reeflective/readline/internal/completion" 29 | "github.com/reeflective/readline/internal/core" 30 | "github.com/reeflective/readline/internal/display" 31 | "github.com/reeflective/readline/internal/history" 32 | "github.com/reeflective/readline/internal/keymap" 33 | "github.com/reeflective/readline/internal/macro" 34 | "github.com/reeflective/readline/internal/term" 35 | ) 36 | 37 | // ErrInterrupt is returned when the interrupt sequence 38 | // is pressed on the keyboard. The sequence is usually Ctrl-C. 39 | var ErrInterrupt = errors.New(os.Interrupt.String()) 40 | 41 | // Readline displays the readline prompt and reads user input. 42 | // It can return from the call because of different things: 43 | // 44 | // - When the user accepts the line (generally with Enter). 45 | // - If a particular keystroke mapping returns an error. 46 | // (Ctrl-C returns ErrInterrupt, Ctrl-D returns io.EOF). 47 | // 48 | // In all cases, the current input line is returned along with any error, 49 | // and it is up to the caller to decide what to do with the line result. 50 | // When the error is not nil, the returned line is not written to history. 51 | func (rl *Shell) Readline() (string, error) { 52 | descriptor := int(os.Stdin.Fd()) 53 | 54 | state, err := term.MakeRaw(descriptor) 55 | if err != nil { 56 | return "", err 57 | } 58 | defer term.Restore(descriptor, state) 59 | 60 | // Prompts and cursor styles 61 | rl.Display.PrintPrimaryPrompt() 62 | defer rl.Display.RefreshTransient() 63 | defer fmt.Print(keymap.CursorStyle("default")) 64 | 65 | rl.init() 66 | 67 | // Terminal resize events 68 | resize := display.WatchResize(rl.Display) 69 | defer close(resize) 70 | 71 | for { 72 | // Whether or not the command is resolved, let the macro 73 | // engine record the keys if currently recording a macro. 74 | // This is done before flushing all used keys, on purpose. 75 | macro.RecordKeys(rl.Macros) 76 | 77 | // Get the rid of the keys that were consumed during the 78 | // previous command run. This may include keys that have 79 | // been consumed but did not match any command. 80 | core.FlushUsed(rl.Keys) 81 | 82 | // Since we always update helpers after being asked to read 83 | // for user input again, we do it before actually reading it. 84 | rl.Display.Refresh() 85 | 86 | // Block and wait for available user input keys. 87 | // These might be read on stdin, or already available because 88 | // the macro engine has fed some keys in bulk when running one. 89 | core.WaitAvailableKeys(rl.Keys, rl.Config) 90 | 91 | // 1 - Local keymap (Completion/Isearch/Vim operator pending). 92 | bind, command, prefixed := keymap.MatchLocal(rl.Keymap) 93 | if prefixed { 94 | continue 95 | } 96 | 97 | accepted, line, err := rl.run(false, bind, command) 98 | if accepted { 99 | return line, err 100 | } else if command != nil { 101 | continue 102 | } 103 | 104 | // Past the local keymap, our actions have a direct effect 105 | // on the line or on the cursor position, so we must first 106 | // "reset" or accept any completion state we're in, if any, 107 | // such as a virtually inserted candidate. 108 | completion.UpdateInserted(rl.completer) 109 | 110 | // 2 - Main keymap (Vim command/insertion, Emacs). 111 | bind, command, prefixed = keymap.MatchMain(rl.Keymap) 112 | if prefixed { 113 | continue 114 | } 115 | 116 | accepted, line, err = rl.run(true, bind, command) 117 | if accepted { 118 | return line, err 119 | } 120 | 121 | // Reaching this point means the last key/sequence has not 122 | // been dispatched down to a command: therefore this key is 123 | // undefined for the current local/main keymaps. 124 | rl.handleUndefined(bind, command) 125 | } 126 | } 127 | 128 | // init gathers all steps to perform at the beginning of readline loop. 129 | func (rl *Shell) init() { 130 | // Reset core editor components. 131 | core.FlushUsed(rl.Keys) 132 | rl.line.Set() 133 | rl.cursor.Set(0) 134 | rl.cursor.ResetMark() 135 | rl.selection.Reset() 136 | rl.Buffers.Reset() 137 | rl.History.Reset() 138 | rl.Iterations.Reset() 139 | 140 | // Some accept-* commands must fetch a specific 141 | // line outright, or keep the accepted one. 142 | history.Init(rl.History) 143 | rl.History.Save() 144 | 145 | // Reset/initialize user interface components. 146 | rl.Hint.Reset() 147 | rl.completer.ResetForce() 148 | display.Init(rl.Display, rl.SyntaxHighlighter) 149 | } 150 | 151 | // run wraps the execution of a target command/sequence with various pre/post actions 152 | // and setup steps (buffers setup, cursor checks, iterations, key flushing, etc...) 153 | func (rl *Shell) run(main bool, bind inputrc.Bind, command func()) (bool, string, error) { 154 | // An empty bind match in the local keymap means nothing 155 | // should be done, the main keymap must work it out. 156 | if !main && bind.Action == "" { 157 | return false, "", nil 158 | } 159 | 160 | // If the resolved bind is a macro itself, reinject its 161 | // bound sequence back to the key stack. 162 | if bind.Macro { 163 | macro := inputrc.Unescape(bind.Action) 164 | rl.Keys.Feed(false, []rune(macro)...) 165 | } 166 | 167 | // The completion system might have control of the 168 | // input line and be using it with a virtual insertion, 169 | // so it knows which line and cursor we should work on. 170 | rl.line, rl.cursor, rl.selection = rl.completer.GetBuffer() 171 | 172 | // The command might be nil, because the provided key sequence 173 | // did not match any. We regardless execute everything related 174 | // to the command, like any pending ones, and cursor checks. 175 | rl.execute(command) 176 | 177 | // Either print/clear iterations/active registers hints. 178 | rl.updatePosRunHints() 179 | 180 | // If the command just run was using the incremental search 181 | // buffer (acting on it), update the list of matches. 182 | rl.completer.UpdateIsearch() 183 | 184 | // Work is done: ask the completion system to 185 | // return the correct input line and cursor. 186 | rl.line, rl.cursor, rl.selection = rl.completer.GetBuffer() 187 | 188 | // History: save the last action to the line history, 189 | // and return with the call to the history system that 190 | // checks if the line has been accepted (entered), in 191 | // which case this will automatically write the history 192 | // sources and set up errors/returned line values. 193 | rl.History.SaveWithCommand(bind) 194 | 195 | return rl.History.LineAccepted() 196 | } 197 | 198 | // Run the dispatched command, any pending operator 199 | // commands (Vim mode) and some post-run checks. 200 | func (rl *Shell) execute(command func()) { 201 | if command != nil { 202 | command() 203 | } 204 | 205 | // Only run pending-operator commands when the command we 206 | // just executed has not had any influence on iterations. 207 | if !rl.Iterations.IsPending() { 208 | rl.Keymap.RunPending() 209 | } 210 | 211 | // Update/check cursor positions after run. 212 | switch rl.Keymap.Main() { 213 | case keymap.ViCommand, keymap.ViMove, keymap.Vi: 214 | rl.cursor.CheckCommand() 215 | default: 216 | rl.cursor.CheckAppend() 217 | } 218 | } 219 | 220 | // Some commands show their current status as a hint (iterations/macro). 221 | func (rl *Shell) updatePosRunHints() { 222 | hint := core.ResetPostRunIterations(rl.Iterations) 223 | register, selected := rl.Buffers.IsSelected() 224 | 225 | if hint == "" && !selected && !rl.Macros.Recording() { 226 | rl.Hint.ResetPersist() 227 | return 228 | } 229 | 230 | if hint != "" { 231 | rl.Hint.Persist(hint) 232 | } else if selected { 233 | rl.Hint.Persist(color.Dim + fmt.Sprintf("(register: %s)", register)) 234 | } 235 | } 236 | 237 | // handleUndefined is in charge of all actions to take when the 238 | // last key/sequence was not dispatched down to a readline command. 239 | func (rl *Shell) handleUndefined(bind inputrc.Bind, cmd func()) { 240 | if bind.Action != "" || cmd != nil { 241 | return 242 | } 243 | 244 | // Undefined keys incremental-search mode cancels it. 245 | if rl.Keymap.Local() == keymap.Isearch { 246 | rl.Hint.Reset() 247 | rl.completer.Reset() 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /shell.go: -------------------------------------------------------------------------------- 1 | package readline 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/reeflective/readline/inputrc" 7 | "github.com/reeflective/readline/internal/completion" 8 | "github.com/reeflective/readline/internal/core" 9 | "github.com/reeflective/readline/internal/display" 10 | "github.com/reeflective/readline/internal/editor" 11 | "github.com/reeflective/readline/internal/history" 12 | "github.com/reeflective/readline/internal/keymap" 13 | "github.com/reeflective/readline/internal/macro" 14 | "github.com/reeflective/readline/internal/term" 15 | "github.com/reeflective/readline/internal/ui" 16 | ) 17 | 18 | // Shell is the main readline shell instance. It contains all the readline state 19 | // and methods to run the line editor, manage the inputrc configuration, keymaps 20 | // and commands. 21 | // Although each instance contains everything needed to run a line editor, it is 22 | // recommended to use a single instance per application, and to share it between 23 | // all the goroutines that need to read user input. 24 | // Please refer to the README and documentation for more details about the shell 25 | // and its components, and how to use them. 26 | type Shell struct { 27 | // Core editor 28 | line *core.Line // The input line buffer and its management methods. 29 | cursor *core.Cursor // The cursor and its methods. 30 | selection *core.Selection // The selection manages various visual/pending selections. 31 | Iterations *core.Iterations // Digit arguments for repeating commands. 32 | Buffers *editor.Buffers // buffers (Vim registers) and methods use/manage/query them. 33 | Keys *core.Keys // Keys is in charge of reading and managing buffered user input. 34 | Keymap *keymap.Engine // Manages main/local keymaps, binds, stores command functions, etc. 35 | History *history.Sources // History manages all history types/sources (past commands and undo) 36 | Macros *macro.Engine // Record, use and display macros. 37 | 38 | // User interface 39 | Config *inputrc.Config // Contains all keymaps, binds and per-application settings. 40 | Opts []inputrc.Option // Inputrc file parsing options (app/term/values, etc). 41 | Prompt *ui.Prompt // The prompt engine computes and renders prompt strings. 42 | Hint *ui.Hint // Usage/hints for completion/isearch below the input line. 43 | completer *completion.Engine // Completions generation and display. 44 | Display *display.Engine // Manages display refresh/update/clearing. 45 | 46 | // User-provided functions 47 | 48 | // AcceptMultiline enables the caller to decide if the shell should keep reading 49 | // for user input on a new line (therefore, with the secondary prompt), or if it 50 | // should return the current line at the end of the `rl.Readline()` call. 51 | // This function should return true if the line is deemed complete (thus asking 52 | // the shell to return from its Readline() loop), or false if the shell should 53 | // keep reading input on a newline (thus, insert a newline and read). 54 | AcceptMultiline func(line []rune) (accept bool) 55 | 56 | // SyntaxHighlighter is a helper function to provide syntax highlighting. 57 | // Once enabled, set to nil to disable again. 58 | SyntaxHighlighter func(line []rune) string 59 | 60 | // Completer is a function that produces completions. 61 | // It takes the readline line ([]rune) and cursor pos as parameters, 62 | // and returns completions with their associated metadata/settings. 63 | Completer func(line []rune, cursor int) Completions 64 | } 65 | 66 | // NewShell returns a readline shell instance initialized with a default 67 | // inputrc configuration and binds, and with an in-memory command history. 68 | // The constructor accepts an optional list of inputrc configuration options, 69 | // which are used when parsing/loading and applying any inputrc configuration. 70 | func NewShell(opts ...inputrc.Option) *Shell { 71 | shell := new(Shell) 72 | 73 | // Core editor 74 | keys := new(core.Keys) 75 | line := new(core.Line) 76 | cursor := core.NewCursor(line) 77 | selection := core.NewSelection(line, cursor) 78 | iterations := new(core.Iterations) 79 | 80 | shell.Keys = keys 81 | shell.line = line 82 | shell.cursor = cursor 83 | shell.selection = selection 84 | shell.Buffers = editor.NewBuffers() 85 | shell.Iterations = iterations 86 | 87 | // Keymaps and commands 88 | keymaps, config := keymap.NewEngine(keys, iterations, opts...) 89 | keymaps.Register(shell.standardCommands()) 90 | keymaps.Register(shell.viCommands()) 91 | keymaps.Register(shell.historyCommands()) 92 | keymaps.Register(shell.completionCommands()) 93 | 94 | shell.Keymap = keymaps 95 | shell.Config = config 96 | shell.Opts = opts 97 | 98 | // User interface 99 | hint := new(ui.Hint) 100 | prompt := ui.NewPrompt(line, cursor, keymaps, config) 101 | macros := macro.NewEngine(keys, hint) 102 | history := history.NewSources(line, cursor, hint, config) 103 | completer := completion.NewEngine(hint, keymaps, config) 104 | completion.Init(completer, keys, line, cursor, selection, shell.commandCompletion) 105 | 106 | display := display.NewEngine(keys, selection, history, prompt, hint, completer, config) 107 | 108 | shell.Config = config 109 | shell.Hint = hint 110 | shell.Prompt = prompt 111 | shell.completer = completer 112 | shell.Macros = macros 113 | shell.History = history 114 | shell.Display = display 115 | 116 | return shell 117 | } 118 | 119 | // Line is the shell input line buffer. 120 | // Contains methods to search and modify its contents, 121 | // split itself with tokenizers, and displaying itself. 122 | // 123 | // When the shell is in incremental-search mode, this line is the minibuffer. 124 | // The line returned here is thus the input buffer of interest at call time. 125 | func (rl *Shell) Line() *core.Line { return rl.line } 126 | 127 | // Cursor is the cursor position in the current line buffer. 128 | // Contains methods to set, move, describe and check itself. 129 | // 130 | // When the shell is in incremental-search mode, this cursor is the minibuffer's one. 131 | // The cursor returned here is thus the input buffer cursor of interest at call time. 132 | func (rl *Shell) Cursor() *core.Cursor { return rl.cursor } 133 | 134 | // Selection contains all regions of an input line that are currently selected/marked 135 | // with either a begin and/or end position. The main selection is the visual one, used 136 | // with the default cursor mark and position, and contains a list of additional surround 137 | // selections used to change/select multiple parts of the line at once. 138 | func (rl *Shell) Selection() *core.Selection { return rl.selection } 139 | 140 | // Printf prints a formatted string below the current line and redisplays the prompt 141 | // and input line (and possibly completions/hints if active) below the logged string. 142 | // A newline is added to the message so that the prompt is correctly refreshed below. 143 | func (rl *Shell) Printf(msg string, args ...any) (n int, err error) { 144 | // First go back to the last line of the input line, 145 | // and clear everything below (hints and completions). 146 | rl.Display.CursorBelowLine() 147 | term.MoveCursorBackwards(term.GetWidth()) 148 | fmt.Print(term.ClearScreenBelow) 149 | 150 | // Skip a line, and print the formatted message. 151 | n, err = fmt.Printf(msg+"\n", args...) 152 | 153 | // Redisplay the prompt, input line and active helpers. 154 | rl.Prompt.PrimaryPrint() 155 | rl.Display.Refresh() 156 | 157 | return 158 | } 159 | 160 | // PrintTransientf prints a formatted string in place of the current prompt and input 161 | // line, and then refreshes, or "pushes" the prompt/line below this printed message. 162 | func (rl *Shell) PrintTransientf(msg string, args ...any) (n int, err error) { 163 | // First go back to the beginning of the line/prompt, and 164 | // clear everything below (prompt/line/hints/completions). 165 | rl.Display.CursorToLineStart() 166 | term.MoveCursorBackwards(term.GetWidth()) 167 | term.MoveCursorUp(rl.Prompt.PrimaryUsed()) 168 | fmt.Print(term.ClearScreenBelow) 169 | 170 | // Print the logged message. 171 | n, err = fmt.Printf(msg+"\n", args...) 172 | 173 | // Redisplay the prompt, input line and active helpers. 174 | rl.Prompt.PrimaryPrint() 175 | rl.Display.Refresh() 176 | 177 | return 178 | } 179 | --------------------------------------------------------------------------------