diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..526c7b3c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,139 @@ +name: Build +on: + pull_request: + push: + branches: + - master + schedule: + - cron: '00 01 * * *' + +jobs: + check: + name: Check + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable, msrv] + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Use MSRV rust toolchain + if: matrix.rust == 'msrv' + run: cp .github/workflows/msrv-rust-toolchain.toml rust-toolchain.toml + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Run cargo check + run: cargo check + + test: + name: Test Suite + runs-on: ${{ matrix.os }} + env: + RUST_BACKTRACE: 1 + HELIX_LOG_LEVEL: info + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Cache test tree-sitter grammar + uses: actions/cache@v3 + with: + path: runtime/grammars + key: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }} + restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars- + + - name: Run cargo test + run: cargo test --workspace + + - name: Run cargo integration-test + run: cargo integration-test + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Run cargo fmt + run: cargo fmt --all -- --check + + - name: Run cargo clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run cargo doc + run: cargo doc --no-deps --workspace --document-private-items + env: + RUSTDOCFLAGS: -D warnings + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Generate docs + run: cargo xtask docgen + + - name: Check uncommitted documentation changes + run: | + git diff + git diff-files --quiet \ + || (echo "Run 'cargo xtask docgen', commit the changes and push again" \ + && exit 1) + + queries: + name: Tree-sitter queries + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Generate docs + run: cargo xtask query-check diff --git a/.github/workflows/build.yml.orig b/.github/workflows/build.yml.orig new file mode 100644 index 00000000..526c7b3c --- /dev/null +++ b/.github/workflows/build.yml.orig @@ -0,0 +1,139 @@ +name: Build +on: + pull_request: + push: + branches: + - master + schedule: + - cron: '00 01 * * *' + +jobs: + check: + name: Check + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable, msrv] + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Use MSRV rust toolchain + if: matrix.rust == 'msrv' + run: cp .github/workflows/msrv-rust-toolchain.toml rust-toolchain.toml + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Run cargo check + run: cargo check + + test: + name: Test Suite + runs-on: ${{ matrix.os }} + env: + RUST_BACKTRACE: 1 + HELIX_LOG_LEVEL: info + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Cache test tree-sitter grammar + uses: actions/cache@v3 + with: + path: runtime/grammars + key: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }} + restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars- + + - name: Run cargo test + run: cargo test --workspace + + - name: Run cargo integration-test + run: cargo integration-test + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Run cargo fmt + run: cargo fmt --all -- --check + + - name: Run cargo clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run cargo doc + run: cargo doc --no-deps --workspace --document-private-items + env: + RUSTDOCFLAGS: -D warnings + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Generate docs + run: cargo xtask docgen + + - name: Check uncommitted documentation changes + run: | + git diff + git diff-files --quiet \ + || (echo "Run 'cargo xtask docgen', commit the changes and push again" \ + && exit 1) + + queries: + name: Tree-sitter queries + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: helix-editor/rust-toolchain@v1 + with: + profile: minimal + override: true + + - uses: Swatinem/rust-cache@v2 + + - name: Generate docs + run: cargo xtask query-check diff --git a/.github/workflows/languages.toml b/.github/workflows/languages.toml index 18cf71cf..b883ba1a 100644 --- a/.github/workflows/languages.toml +++ b/.github/workflows/languages.toml @@ -11,7 +11,7 @@ indent = { tab-width = 4, unit = " " } [[grammar]] name = "rust" -source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a360da0a29a19c281d08295a35ecd0544d2da211" } +source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "0431a2c60828731f27491ee9fdefe25e250ce9c9" } [[language]] name = "nix" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2058232..1672933c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,10 @@ jobs: profile: minimal override: true - - uses: Swatinem/rust-cache@v1 + - uses: Swatinem/rust-cache@v2 - name: Fetch tree-sitter grammars - uses: actions-rs/cargo@v1 - with: - command: run - args: --package=helix-loader --bin=hx-loader + run: cargo run --package=helix-loader --bin=hx-loader - name: Bundle grammars run: tar cJf grammars.tar.xz -C runtime/grammars/sources . @@ -198,16 +195,6 @@ jobs: - uses: actions/download-artifact@v3 - - name: Calculate tag name - run: | - name=dev - if [[ $GITHUB_REF == refs/tags/* ]]; then - name=${GITHUB_REF:10} - fi - echo ::set-output name=val::$name - echo TAG=$name >> $GITHUB_ENV - id: tagname - - name: Build archive shell: bash run: | @@ -227,7 +214,7 @@ jobs: if [[ $platform =~ "windows" ]]; then exe=".exe" fi - pkgname=helix-$TAG-$platform + pkgname=helix-$GITHUB_REF_NAME-$platform mkdir $pkgname cp $source/LICENSE $source/README.md $pkgname mkdir $pkgname/contrib @@ -247,7 +234,7 @@ jobs: fi done - tar cJf dist/helix-$TAG-source.tar.xz -C $source . + tar cJf dist/helix-$GITHUB_REF_NAME-source.tar.xz -C $source . mv dist $source/ - name: Upload binaries to release @@ -257,7 +244,7 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} file: dist/* file_glob: true - tag: ${{ steps.tagname.outputs.val }} + tag: ${{ github.ref_name }} overwrite: true - name: Upload binaries as artifact diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/helix.iml b/.idea/helix.iml new file mode 100644 index 00000000..bc2cd874 --- /dev/null +++ b/.idea/helix.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..03d9549e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..6c65ec58 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 93459aa0..616c5317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6ccdb167abbf410dcb915cabd428929d7f6a04980b54a11f26a39f1c7f7107" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "0.7.18" @@ -92,9 +104,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.0.74" +version = "1.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581f5dba903aac52ea3feb5ec4810848460ee833876f1f9b0fdeab1f19091574" +checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4" [[package]] name = "cfg-if" @@ -115,9 +127,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.22" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" dependencies = [ "iana-time-zone", "num-integer", @@ -400,18 +412,29 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.6", +] + +[[package]] +name = "hashbrown" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ff8ae62cd3a9102e5637afc8452c55acf3844001bd5374e0b0bd7b6616c038" +dependencies = [ + "ahash 0.8.2", ] [[package]] name = "helix-core" version = "0.6.0" dependencies = [ + "ahash 0.8.2", "arc-swap", "bitflags", "chrono", "encoding_rs", "etcetera", + "hashbrown 0.13.1", "helix-loader", "log", "once_cell", @@ -655,9 +678,9 @@ checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" [[package]] name = "libloading" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbc0f03f9a775e9f6aed295c6a1ba2253c5757a9e03d55c6caa46a681abcddd" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ "cfg-if", "winapi", @@ -876,9 +899,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" dependencies = [ "aho-corasick", "memchr", @@ -959,9 +982,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.87" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" +checksum = "8e8b3801309262e8184d9687fb697586833e939767aea0dda89f5a8e650e8bd7" dependencies = [ "itoa", "ryu", @@ -1023,9 +1046,9 @@ dependencies = [ [[package]] name = "similar" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ac7f900db32bf3fd12e0117dd3dc4da74bc52ebaac97f39668446d89694803" +checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf" [[package]] name = "slab" @@ -1196,9 +1219,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.21.2" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099" +checksum = "d76ce4a75fb488c605c54bf610f221cea8b0dafb53333c1a67e8ee199dcd2ae3" dependencies = [ "autocfg", "bytes", @@ -1288,7 +1311,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" dependencies = [ - "hashbrown", + "hashbrown 0.12.3", "regex", ] diff --git a/README.md b/README.md index 0ba1f696..ed2ec438 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,10 @@ config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%App | -------------------- | ------------------------------------------------ | | Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` | | Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` | -| Linux / MacOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | +| Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires -elevated priviliges - i.e. PowerShell or Cmd must be run as administrator. +elevated privileges - i.e. PowerShell or Cmd must be run as administrator. **PowerShell:** @@ -135,9 +135,9 @@ sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desk Please note: there is no icon for Helix yet, so the system default will be used. -## MacOS +## macOS -Helix can be installed on MacOS through homebrew: +Helix can be installed on macOS through homebrew: ``` brew install helix diff --git a/book/src/configuration.md b/book/src/configuration.md index 41563f4f..ea8fd76f 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -46,7 +46,7 @@ on unix operating systems. | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `cursorline` | Highlight all lines with a cursor. | `false` | | `cursorcolumn` | Highlight all columns with a cursor. | `false` | -| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "line-numbers"]` | +| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers"]` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `auto-format` | Enable automatic formatting on save. | `true` | | `auto-save` | Enable automatic saving on focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal. | `false` | @@ -103,7 +103,9 @@ The following statusline elements can be configured: | `total-line-numbers` | The total line numbers of the opened file | | `file-type` | The type of the opened file | | `diagnostics` | The number of warnings and/or errors | +| `workspace-diagnostics` | The number of warnings and/or errors on workspace | | `selections` | The number of active selections | +| `primary-selection-length` | The number of characters currently in primary selection | | `position` | The cursor position | | `position-percentage` | The cursor position as a percentage of the total number of lines | | `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) | diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 411e67b8..487057e6 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -5,6 +5,7 @@ | bash | ✓ | | | `bash-language-server` | | bass | ✓ | | | `bass` | | beancount | ✓ | | | | +| bicep | ✓ | | | `bicep-langserver` | | c | ✓ | ✓ | ✓ | `clangd` | | c-sharp | ✓ | ✓ | | `OmniSharp` | | cairo | ✓ | | | | @@ -24,7 +25,7 @@ | edoc | ✓ | | | | | eex | ✓ | | | | | ejs | ✓ | | | | -| elixir | ✓ | ✓ | | `elixir-ls` | +| elixir | ✓ | ✓ | ✓ | `elixir-ls` | | elm | ✓ | | | `elm-language-server` | | elvish | ✓ | | | `elvish` | | env | ✓ | | | | @@ -50,12 +51,12 @@ | hare | ✓ | | | | | haskell | ✓ | | | `haskell-language-server-wrapper` | | hcl | ✓ | | ✓ | `terraform-ls` | -| heex | ✓ | ✓ | | | +| heex | ✓ | ✓ | | `elixir-ls` | | html | ✓ | | | `vscode-html-language-server` | | idris | | | | `idris2-lsp` | | iex | ✓ | | | | | ini | ✓ | | | | -| java | ✓ | | | `jdtls` | +| java | ✓ | ✓ | | `jdtls` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | jsdoc | ✓ | | | | | json | ✓ | | ✓ | `vscode-json-language-server` | @@ -77,7 +78,7 @@ | meson | ✓ | | ✓ | | | mint | | | | `mint` | | nickel | ✓ | | ✓ | `nls` | -| nix | ✓ | | | `rnix-lsp` | +| nix | ✓ | | | `nil` | | nu | ✓ | | | | | ocaml | ✓ | | ✓ | `ocamllsp` | | ocaml-interface | ✓ | | | `ocamllsp` | @@ -92,6 +93,7 @@ | protobuf | ✓ | | ✓ | | | purescript | ✓ | | | `purescript-language-server` | | python | ✓ | ✓ | ✓ | `pylsp` | +| qml | ✓ | | ✓ | `qmlls` | | r | ✓ | | | `R` | | racket | | | | `racket` | | regex | ✓ | | | | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index adf1b4c6..9f01d2f5 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -44,7 +44,9 @@ | `:show-directory`, `:pwd` | Show the current working directory. | | `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. | | `:reload` | Discard changes and reload from the source file. | +| `:reload-all` | Discard changes and reload all documents from the source files. | | `:update` | Write changes only if the file has been modified. | +| `:lsp-workspace-command` | Open workspace command picker | | `:lsp-restart` | Restarts the Language Server that is in use by the current doc | | `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. | | `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. | diff --git a/book/src/install.md b/book/src/install.md index a041d651..44f13584 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -103,10 +103,10 @@ via the `HELIX_RUNTIME` environment variable. | -------------------- | ------------------------------------------------ | | Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` | | Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` | -| Linux / MacOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | +| Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires -elevated priviliges - i.e. PowerShell or Cmd must be run as administrator. +elevated privileges - i.e. PowerShell or Cmd must be run as administrator. **PowerShell:** diff --git a/contrib/completion/hx.bash b/contrib/completion/hx.bash index 89f3283c..01b42deb 100644 --- a/contrib/completion/hx.bash +++ b/contrib/completion/hx.bash @@ -19,5 +19,5 @@ _hx() { COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- $2)) ;; esac -} && complete -F _hx hx +} && complete -o filenames -F _hx hx diff --git a/flake.lock b/flake.lock index 74206d2b..f097519e 100644 --- a/flake.lock +++ b/flake.lock @@ -1,22 +1,5 @@ { "nodes": { - "all-cabal-json": { - "flake": false, - "locked": { - "lastModified": 1665552503, - "narHash": "sha256-r14RmRSwzv5c+bWKUDaze6pXM7nOsiz1H8nvFHJvufc=", - "owner": "nix-community", - "repo": "all-cabal-json", - "rev": "d7c0434eebffb305071404edcf9d5cd99703878e", - "type": "github" - }, - "original": { - "owner": "nix-community", - "ref": "hackage", - "repo": "all-cabal-json", - "type": "github" - } - }, "crane": { "flake": false, "locked": { @@ -52,47 +35,45 @@ "dream2nix": { "inputs": { "alejandra": [ - "nci", - "nixpkgs" + "nci" + ], + "all-cabal-json": [ + "nci" ], - "all-cabal-json": "all-cabal-json", "crane": "crane", "devshell": [ "nci", "devshell" ], "flake-utils-pre-commit": [ - "nci", - "nixpkgs" + "nci" + ], + "ghc-utils": [ + "nci" ], - "ghc-utils": "ghc-utils", "gomod2nix": [ - "nci", - "nixpkgs" + "nci" ], "mach-nix": [ - "nci", - "nixpkgs" + "nci" ], "nixpkgs": [ "nci", "nixpkgs" ], "poetry2nix": [ - "nci", - "nixpkgs" + "nci" ], "pre-commit-hooks": [ - "nci", - "nixpkgs" + "nci" ] }, "locked": { - "lastModified": 1667429039, - "narHash": "sha256-Lu6da25JioHzerkLHAHSO9suCQFzJ/XBjkcGCIbasLM=", + "lastModified": 1668851003, + "narHash": "sha256-X7RCQQynbxStZR2m7HW38r/msMQwVl3afD6UXOCtvx4=", "owner": "nix-community", "repo": "dream2nix", - "rev": "5252794e58eedb02d607fa3187ffead7becc81b0", + "rev": "c77e8379d8fe01213ba072e40946cbfb7b58e628", "type": "github" }, "original": { @@ -116,22 +97,6 @@ "type": "github" } }, - "ghc-utils": { - "flake": false, - "locked": { - "lastModified": 1662774800, - "narHash": "sha256-1Rd2eohGUw/s1tfvkepeYpg8kCEXiIot0RijapUjAkE=", - "ref": "refs/heads/master", - "rev": "bb3a2d3dc52ff0253fb9c2812bd7aa2da03e0fea", - "revCount": 1072, - "type": "git", - "url": "https://gitlab.haskell.org/bgamari/ghc-utils" - }, - "original": { - "type": "git", - "url": "https://gitlab.haskell.org/bgamari/ghc-utils" - } - }, "nci": { "inputs": { "devshell": "devshell", @@ -144,11 +109,11 @@ ] }, "locked": { - "lastModified": 1667542401, - "narHash": "sha256-mdWjP5tjSf8n6FAtpSgL23kX4+eWBwLrSYo9iY3mA8Q=", + "lastModified": 1669011203, + "narHash": "sha256-Lymj4HktNEFmVXtwI0Os7srDXHZbZW0Nzw3/+5Hf8ko=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "cd5e5cbd81c80dc219455dd3b1e0ddb55fae51ec", + "rev": "c5133b91fc1d549087c91228bd213f2518728a4b", "type": "github" }, "original": { @@ -159,11 +124,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1667482890, - "narHash": "sha256-pua0jp87iwN7NBY5/ypx0s9L9CG49Ju/NI4wGwurHc4=", + "lastModified": 1668905981, + "narHash": "sha256-RBQa/+9Uk1eFTqIOXBSBezlEbA3v5OkgP+qptQs1OxY=", "owner": "nixos", "repo": "nixpkgs", - "rev": "a2a777538d971c6b01c6e54af89ddd6567c055e8", + "rev": "690ffff026b4e635b46f69002c0f4e81c65dfc2e", "type": "github" }, "original": { @@ -188,11 +153,11 @@ ] }, "locked": { - "lastModified": 1667487142, - "narHash": "sha256-bVuzLs1ZVggJAbJmEDVO9G6p8BH3HRaolK70KXvnWnU=", + "lastModified": 1668998422, + "narHash": "sha256-G/BklIplCHZEeDIabaaxqgITdIXtMolRGlwxn9jG2/Q=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "cf668f737ac986c0a89e83b6b2e3c5ddbd8cf33b", + "rev": "68ab029c93f8f8eed4cf3ce9a89a9fd4504b2d6e", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b1d3f01e..fe1c6b44 100644 --- a/flake.nix +++ b/flake.nix @@ -150,6 +150,7 @@ ["languages.toml" "theme.toml" "base16_theme.toml"] } ''; + checkPhase = ":"; meta.mainProgram = "hx"; }; @@ -166,7 +167,7 @@ packages // { helix-unwrapped = packages.helix.passthru.unwrapped; - helix-unwrapped-debug = packages.helix-debug.passthru.unwrapped; + helix-unwrapped-dev = packages.helix-dev.passthru.unwrapped; } ) outputs.packages; diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 45272f98..eb886c90 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -30,6 +30,8 @@ once_cell = "1.16" arc-swap = "1" regex = "1" bitflags = "1.3" +ahash = "0.8.2" +hashbrown = { version = "0.13.1", features = ["raw"] } log = "0.4" serde = { version = "1.0", features = ["derive"] } diff --git a/helix-core/src/comment.rs b/helix-core/src/comment.rs index 44f6cdfe..ec5d7a45 100644 --- a/helix-core/src/comment.rs +++ b/helix-core/src/comment.rs @@ -100,43 +100,41 @@ mod test { #[test] fn test_find_line_comment() { - use crate::State; - // four lines, two space indented, except for line 1 which is blank. - let doc = Rope::from(" 1\n\n 2\n 3"); - - let mut state = State::new(doc); + let mut doc = Rope::from(" 1\n\n 2\n 3"); // select whole document - state.selection = Selection::single(0, state.doc.len_chars() - 1); + let mut selection = Selection::single(0, doc.len_chars() - 1); - let text = state.doc.slice(..); + let text = doc.slice(..); let res = find_line_comment("//", text, 0..3); // (commented = true, to_change = [line 0, line 2], min = col 2, margin = 1) assert_eq!(res, (false, vec![0, 2], 2, 1)); // comment - let transaction = toggle_line_comments(&state.doc, &state.selection, None); - transaction.apply(&mut state.doc); - state.selection = state.selection.map(transaction.changes()); + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); - assert_eq!(state.doc, " // 1\n\n // 2\n // 3"); + assert_eq!(doc, " // 1\n\n // 2\n // 3"); // uncomment - let transaction = toggle_line_comments(&state.doc, &state.selection, None); - transaction.apply(&mut state.doc); - state.selection = state.selection.map(transaction.changes()); - assert_eq!(state.doc, " 1\n\n 2\n 3"); + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + assert_eq!(doc, " 1\n\n 2\n 3"); + assert!(selection.len() == 1); // to ignore the selection unused warning // 0 margin comments - state.doc = Rope::from(" //1\n\n //2\n //3"); + doc = Rope::from(" //1\n\n //2\n //3"); // reset the selection. - state.selection = Selection::single(0, state.doc.len_chars() - 1); + selection = Selection::single(0, doc.len_chars() - 1); - let transaction = toggle_line_comments(&state.doc, &state.selection, None); - transaction.apply(&mut state.doc); - state.selection = state.selection.map(transaction.changes()); - assert_eq!(state.doc, " 1\n\n 2\n 3"); + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + assert_eq!(doc, " 1\n\n 2\n 3"); + assert!(selection.len() == 1); // to ignore the selection unused warning // TODO: account for uncommenting with uneven comment indentation } diff --git a/helix-core/src/history.rs b/helix-core/src/history.rs index 5cd72b07..82509242 100644 --- a/helix-core/src/history.rs +++ b/helix-core/src/history.rs @@ -1,9 +1,15 @@ -use crate::{Assoc, ChangeSet, Range, Rope, State, Transaction}; +use crate::{Assoc, ChangeSet, Range, Rope, Selection, Transaction}; use once_cell::sync::Lazy; use regex::Regex; use std::num::NonZeroUsize; use std::time::{Duration, Instant}; +#[derive(Debug, Clone)] +pub struct State { + pub doc: Rope, + pub selection: Selection, +} + /// Stores the history of changes to a buffer. /// /// Currently the history is represented as a vector of revisions. The vector @@ -48,7 +54,7 @@ pub struct History { } /// A single point in history. See [History] for more information. -#[derive(Debug)] +#[derive(Debug, Clone)] struct Revision { parent: usize, last_child: Option, @@ -113,6 +119,37 @@ impl History { self.current == 0 } + /// Returns the changes since the given revision composed into a transaction. + /// Returns None if there are no changes between the current and given revisions. + pub fn changes_since(&self, revision: usize) -> Option { + use std::cmp::Ordering::*; + + match revision.cmp(&self.current) { + Equal => None, + Less => { + let mut child = self.revisions[revision].last_child?.get(); + let mut transaction = self.revisions[child].transaction.clone(); + while child != self.current { + child = self.revisions[child].last_child?.get(); + transaction = transaction.compose(self.revisions[child].transaction.clone()); + } + Some(transaction) + } + Greater => { + let mut inversion = self.revisions[revision].inversion.clone(); + let mut parent = self.revisions[revision].parent; + while parent != self.current { + parent = self.revisions[parent].parent; + if parent == 0 { + return None; + } + inversion = inversion.compose(self.revisions[parent].inversion.clone()); + } + Some(inversion) + } + } + } + /// Undo the last edit. pub fn undo(&mut self) -> Option<&Transaction> { if self.at_root() { @@ -366,12 +403,16 @@ impl std::str::FromStr for UndoKind { #[cfg(test)] mod test { use super::*; + use crate::Selection; #[test] fn test_undo_redo() { let mut history = History::default(); let doc = Rope::from("hello"); - let mut state = State::new(doc); + let mut state = State { + doc, + selection: Selection::point(0), + }; let transaction1 = Transaction::change(&state.doc, vec![(5, 5, Some(" world!".into()))].into_iter()); @@ -420,7 +461,10 @@ mod test { fn test_earlier_later() { let mut history = History::default(); let doc = Rope::from("a\n"); - let mut state = State::new(doc); + let mut state = State { + doc, + selection: Selection::point(0), + }; fn undo(history: &mut History, state: &mut State) { if let Some(transaction) = history.undo() { diff --git a/helix-core/src/increment/date_time.rs b/helix-core/src/increment/date_time.rs index 1574bf4d..265242ce 100644 --- a/helix-core/src/increment/date_time.rs +++ b/helix-core/src/increment/date_time.rs @@ -74,12 +74,12 @@ impl DateTimeIncrementor { (true, false) => { let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?; - date.and_hms(0, 0, 0) + date.and_hms_opt(0, 0, 0).unwrap() } (false, true) => { let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?; - NaiveDate::from_ymd(0, 1, 1).and_time(time) + NaiveDate::from_ymd_opt(0, 1, 1).unwrap().and_time(time) } (false, false) => return None, }; @@ -312,10 +312,10 @@ fn ndays_in_month(year: i32, month: u32) -> u32 { } else { (year, month + 1) }; - let d = NaiveDate::from_ymd(y, m, 1); + let d = NaiveDate::from_ymd_opt(y, m, 1).unwrap(); // ...is preceded by the last day of the original month. - d.pred().day() + d.pred_opt().unwrap().day() } fn add_months(date_time: NaiveDateTime, amount: i64) -> Option { @@ -334,7 +334,7 @@ fn add_months(date_time: NaiveDateTime, amount: i64) -> Option { let day = cmp::min(date_time.day(), ndays_in_month(year, month)); - Some(NaiveDate::from_ymd(year, month, day).and_time(date_time.time())) + NaiveDate::from_ymd_opt(year, month, day).map(|date| date.and_time(date_time.time())) } fn add_years(date_time: NaiveDateTime, amount: i64) -> Option { @@ -342,8 +342,8 @@ fn add_years(date_time: NaiveDateTime, amount: i64) -> Option { let ndays = ndays_in_month(year, date_time.month()); if date_time.day() > ndays { - let d = NaiveDate::from_ymd(year, date_time.month(), ndays); - Some(d.succ().and_time(date_time.time())) + NaiveDate::from_ymd_opt(year, date_time.month(), ndays) + .and_then(|date| date.succ_opt().map(|date| date.and_time(date_time.time()))) } else { date_time.with_year(year) } diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 9526fc8a..d6aa5edb 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -461,59 +461,61 @@ fn query_indents( /// so that the indent computation starts with the correct syntax node. fn extend_nodes<'a>( node: &mut Node<'a>, - deepest_preceding: Option>, + mut deepest_preceding: Node<'a>, extend_captures: &HashMap>, text: RopeSlice, line: usize, tab_width: usize, ) { - if let Some(mut deepest_preceding) = deepest_preceding { - let mut stop_extend = false; - while deepest_preceding != *node { - let mut extend_node = false; - // This will be set to true if this node is captured, regardless of whether - // it actually will be extended (e.g. because the cursor isn't indented - // more than the node). - let mut node_captured = false; - if let Some(captures) = extend_captures.get(&deepest_preceding.id()) { - for capture in captures { - match capture { - ExtendCapture::PreventOnce => { - stop_extend = true; - } - ExtendCapture::Extend => { - node_captured = true; - // We extend the node if - // - the cursor is on the same line as the end of the node OR - // - the line that the cursor is on is more indented than the - // first line of the node - if deepest_preceding.end_position().row == line { + let mut stop_extend = false; + + while deepest_preceding != *node { + let mut extend_node = false; + // This will be set to true if this node is captured, regardless of whether + // it actually will be extended (e.g. because the cursor isn't indented + // more than the node). + let mut node_captured = false; + if let Some(captures) = extend_captures.get(&deepest_preceding.id()) { + for capture in captures { + match capture { + ExtendCapture::PreventOnce => { + stop_extend = true; + } + ExtendCapture::Extend => { + node_captured = true; + // We extend the node if + // - the cursor is on the same line as the end of the node OR + // - the line that the cursor is on is more indented than the + // first line of the node + if deepest_preceding.end_position().row == line { + extend_node = true; + } else { + let cursor_indent = indent_level_for_line(text.line(line), tab_width); + let node_indent = indent_level_for_line( + text.line(deepest_preceding.start_position().row), + tab_width, + ); + if cursor_indent > node_indent { extend_node = true; - } else { - let cursor_indent = - indent_level_for_line(text.line(line), tab_width); - let node_indent = indent_level_for_line( - text.line(deepest_preceding.start_position().row), - tab_width, - ); - if cursor_indent > node_indent { - extend_node = true; - } } } } } } - // If we encountered some `StopExtend` capture before, we don't - // extend the node even if we otherwise would - if node_captured && stop_extend { - stop_extend = false; - } else if extend_node && !stop_extend { - *node = deepest_preceding; - break; - } - // This parent always exists since node is an ancestor of deepest_preceding - deepest_preceding = deepest_preceding.parent().unwrap(); + } + // If we encountered some `StopExtend` capture before, we don't + // extend the node even if we otherwise would + if node_captured && stop_extend { + stop_extend = false; + } else if extend_node && !stop_extend { + *node = deepest_preceding; + break; + } + // If the tree contains a syntax error, `deepest_preceding` may not + // have a parent despite being a descendant of `node`. + deepest_preceding = match deepest_preceding.parent() { + Some(parent) => parent, + None => return, } } } @@ -612,14 +614,16 @@ pub fn treesitter_indent_for_pos( let extend_captures = query_result.extend_captures; // Check for extend captures, potentially changing the node that the indent calculation starts with - extend_nodes( - &mut node, - deepest_preceding, - &extend_captures, - text, - line, - tab_width, - ); + if let Some(deepest_preceding) = deepest_preceding { + extend_nodes( + &mut node, + deepest_preceding, + &extend_captures, + text, + line, + tab_width, + ); + } let mut first_in_line = get_first_in_line(node, new_line.then(|| byte_pos)); let mut result = Indentation::default(); diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 8f869e35..5f60c048 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -21,7 +21,6 @@ pub mod register; pub mod search; pub mod selection; pub mod shellwords; -mod state; pub mod surround; pub mod syntax; pub mod test; @@ -103,7 +102,6 @@ pub use smallvec::{smallvec, SmallVec}; pub use syntax::Syntax; pub use diagnostic::Diagnostic; -pub use state::State; pub use line_ending::{LineEnding, DEFAULT_LINE_ENDING}; pub use transaction::{Assoc, Change, ChangeSet, Operation, Transaction}; diff --git a/helix-core/src/register.rs b/helix-core/src/register.rs index 1cff77ba..52eb6e3e 100644 --- a/helix-core/src/register.rs +++ b/helix-core/src/register.rs @@ -15,11 +15,7 @@ impl Register { } pub fn new_with_values(name: char, values: Vec) -> Self { - if name == '_' { - Self::new(name) - } else { - Self { name, values } - } + Self { name, values } } pub const fn name(&self) -> char { @@ -31,15 +27,11 @@ impl Register { } pub fn write(&mut self, values: Vec) { - if self.name != '_' { - self.values = values; - } + self.values = values; } pub fn push(&mut self, value: String) { - if self.name != '_' { - self.values.push(value); - } + self.values.push(value); } } @@ -54,19 +46,25 @@ impl Registers { self.inner.get(&name) } - pub fn get_mut(&mut self, name: char) -> &mut Register { - self.inner - .entry(name) - .or_insert_with(|| Register::new(name)) + pub fn read(&self, name: char) -> Option<&[String]> { + self.get(name).map(|reg| reg.read()) } pub fn write(&mut self, name: char, values: Vec) { - self.inner - .insert(name, Register::new_with_values(name, values)); + if name != '_' { + self.inner + .insert(name, Register::new_with_values(name, values)); + } } - pub fn read(&self, name: char) -> Option<&[String]> { - self.get(name).map(|reg| reg.read()) + pub fn push(&mut self, name: char, value: String) { + if name != '_' { + if let Some(r) = self.inner.get_mut(&name) { + r.push(value); + } else { + self.write(name, vec![value]); + } + } } pub fn first(&self, name: char) -> Option<&String> { diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index e8c5945b..9475f5e5 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; /// Auto escape for shellwords usage. -pub fn escape(input: &str) -> Cow<'_, str> { +pub fn escape(input: Cow) -> Cow { if !input.chars().any(|x| x.is_ascii_whitespace()) { - Cow::Borrowed(input) + input } else if cfg!(unix) { Cow::Owned(input.chars().fold(String::new(), |mut buf, c| { if c.is_ascii_whitespace() { @@ -17,127 +17,182 @@ pub fn escape(input: &str) -> Cow<'_, str> { } } -/// Get the vec of escaped / quoted / doublequoted filenames from the input str -pub fn shellwords(input: &str) -> Vec> { - enum State { - OnWhitespace, - Unquoted, - UnquotedEscaped, - Quoted, - QuoteEscaped, - Dquoted, - DquoteEscaped, - } +enum State { + OnWhitespace, + Unquoted, + UnquotedEscaped, + Quoted, + QuoteEscaped, + Dquoted, + DquoteEscaped, +} - use State::*; +pub struct Shellwords<'a> { + state: State, + /// Shellwords where whitespace and escapes has been resolved. + words: Vec>, + /// The parts of the input that are divided into shellwords. This can be + /// used to retrieve the original text for a given word by looking up the + /// same index in the Vec as the word in `words`. + parts: Vec<&'a str>, +} - let mut state = Unquoted; - let mut args: Vec> = Vec::new(); - let mut escaped = String::with_capacity(input.len()); +impl<'a> From<&'a str> for Shellwords<'a> { + fn from(input: &'a str) -> Self { + use State::*; - let mut start = 0; - let mut end = 0; + let mut state = Unquoted; + let mut words = Vec::new(); + let mut parts = Vec::new(); + let mut escaped = String::with_capacity(input.len()); - for (i, c) in input.char_indices() { - state = match state { - OnWhitespace => match c { - '"' => { - end = i; - Dquoted - } - '\'' => { - end = i; - Quoted - } - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - UnquotedEscaped - } else { + let mut part_start = 0; + let mut unescaped_start = 0; + let mut end = 0; + + for (i, c) in input.char_indices() { + state = match state { + OnWhitespace => match c { + '"' => { + end = i; + Dquoted + } + '\'' => { + end = i; + Quoted + } + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + OnWhitespace + } + } + c if c.is_ascii_whitespace() => { + end = i; OnWhitespace } - } - c if c.is_ascii_whitespace() => { - end = i; - OnWhitespace - } - _ => Unquoted, - }, - Unquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - UnquotedEscaped - } else { - Unquoted + _ => Unquoted, + }, + Unquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + Unquoted + } } - } - c if c.is_ascii_whitespace() => { - end = i; - OnWhitespace - } - _ => Unquoted, - }, - UnquotedEscaped => Unquoted, - Quoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - QuoteEscaped - } else { - Quoted + c if c.is_ascii_whitespace() => { + end = i; + OnWhitespace } - } - '\'' => { - end = i; - OnWhitespace - } - _ => Quoted, - }, - QuoteEscaped => Quoted, - Dquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - DquoteEscaped - } else { - Dquoted + _ => Unquoted, + }, + UnquotedEscaped => Unquoted, + Quoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + QuoteEscaped + } else { + Quoted + } } - } - '"' => { - end = i; - OnWhitespace - } - _ => Dquoted, - }, - DquoteEscaped => Dquoted, - }; + '\'' => { + end = i; + OnWhitespace + } + _ => Quoted, + }, + QuoteEscaped => Quoted, + Dquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + DquoteEscaped + } else { + Dquoted + } + } + '"' => { + end = i; + OnWhitespace + } + _ => Dquoted, + }, + DquoteEscaped => Dquoted, + }; - if i >= input.len() - 1 && end == 0 { - end = i + 1; - } + if i >= input.len() - 1 && end == 0 { + end = i + 1; + } - if end > 0 { - let esc_trim = escaped.trim(); - let inp = &input[start..end]; + if end > 0 { + let esc_trim = escaped.trim(); + let inp = &input[unescaped_start..end]; - if !(esc_trim.is_empty() && inp.trim().is_empty()) { - if esc_trim.is_empty() { - args.push(inp.into()); - } else { - args.push([escaped, inp.into()].concat().into()); - escaped = "".to_string(); + if !(esc_trim.is_empty() && inp.trim().is_empty()) { + if esc_trim.is_empty() { + words.push(inp.into()); + parts.push(inp); + } else { + words.push([escaped, inp.into()].concat().into()); + parts.push(&input[part_start..end]); + escaped = "".to_string(); + } } + unescaped_start = i + 1; + part_start = i + 1; + end = 0; } - start = i + 1; - end = 0; } + + debug_assert!(words.len() == parts.len()); + + Self { + state, + words, + parts, + } + } +} + +impl<'a> Shellwords<'a> { + /// Checks that the input ends with a whitespace character which is not escaped. + /// + /// # Examples + /// + /// ```rust + /// use helix_core::shellwords::Shellwords; + /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true); + /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true); + /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true); + /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false); + /// #[cfg(unix)] + /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false); + /// #[cfg(unix)] + /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false); + /// ``` + pub fn ends_with_whitespace(&self) -> bool { + matches!(self.state, State::OnWhitespace) + } + + /// Returns the list of shellwords calculated from the input string. + pub fn words(&self) -> &[Cow<'a, str>] { + &self.words + } + + /// Returns a list of strings which correspond to [`Self::words`] but represent the original + /// text in the input string - including escape characters - without separating whitespace. + pub fn parts(&self) -> &[&'a str] { + &self.parts } - args } #[cfg(test)] @@ -148,7 +203,8 @@ mod test { #[cfg(windows)] fn test_normal() { let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; - let result = shellwords(input); + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":o"), Cow::from("single_word"), @@ -166,7 +222,8 @@ mod test { #[cfg(unix)] fn test_normal() { let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; - let result = shellwords(input); + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":o"), Cow::from("single_word"), @@ -183,7 +240,8 @@ mod test { fn test_quoted() { let quoted = r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; - let result = shellwords(quoted); + let shellwords = Shellwords::from(quoted); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":o"), Cow::from("single_word"), @@ -198,7 +256,8 @@ mod test { #[cfg(unix)] fn test_dquoted() { let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; - let result = shellwords(dquoted); + let shellwords = Shellwords::from(dquoted); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":o"), Cow::from("single_word"), @@ -213,7 +272,8 @@ mod test { #[cfg(unix)] fn test_mixed() { let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; - let result = shellwords(dquoted); + let shellwords = Shellwords::from(dquoted); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":o"), Cow::from("single_word"), @@ -234,7 +294,8 @@ mod test { fn test_lists() { let input = r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#; - let result = shellwords(input); + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":set"), Cow::from("statusline.center"), @@ -247,15 +308,29 @@ mod test { #[test] #[cfg(unix)] fn test_escaping_unix() { - assert_eq!(escape("foobar"), Cow::Borrowed("foobar")); - assert_eq!(escape("foo bar"), Cow::Borrowed("foo\\ bar")); - assert_eq!(escape("foo\tbar"), Cow::Borrowed("foo\\\tbar")); + assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); + assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar")); + assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar")); } #[test] #[cfg(windows)] fn test_escaping_windows() { - assert_eq!(escape("foobar"), Cow::Borrowed("foobar")); - assert_eq!(escape("foo bar"), Cow::Borrowed("\"foo bar\"")); + assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); + assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\"")); + } + + #[test] + #[cfg(unix)] + fn test_parts() { + assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]); + assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]); + } + + #[test] + #[cfg(windows)] + fn test_parts() { + assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]); + assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]); } } diff --git a/helix-core/src/state.rs b/helix-core/src/state.rs deleted file mode 100644 index dcc4b11b..00000000 --- a/helix-core/src/state.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::{Rope, Selection}; - -#[derive(Debug, Clone)] -pub struct State { - pub doc: Rope, - pub selection: Selection, -} - -impl State { - #[must_use] - pub fn new(doc: Rope) -> Self { - Self { - doc, - selection: Selection::point(0), - } - } -} diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 0f62577f..8dc34a3e 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -7,8 +7,10 @@ use crate::{ Rope, RopeSlice, Tendril, }; +use ahash::RandomState; use arc_swap::{ArcSwap, Guard}; use bitflags::bitflags; +use hashbrown::raw::RawTable; use slotmap::{DefaultKey as LayerId, HopSlotMap}; use std::{ @@ -16,7 +18,8 @@ use std::{ cell::RefCell, collections::{HashMap, VecDeque}, fmt, - mem::replace, + hash::{Hash, Hasher}, + mem::{replace, transmute}, path::Path, str::FromStr, sync::Arc, @@ -354,6 +357,26 @@ impl<'a> CapturedNode<'a> { } } +/// The maximum number of in-progress matches a TS cursor can consider at once. +/// This is set to a constant in order to avoid performance problems for medium to large files. Set with `set_match_limit`. +/// Using such a limit means that we lose valid captures, so there is fundamentally a tradeoff here. +/// +/// +/// Old tree sitter versions used a limit of 32 by default until this limit was removed in version `0.19.5` (must now be set manually). +/// However, this causes performance issues for medium to large files. +/// In helix, this problem caused treesitter motions to take multiple seconds to complete in medium-sized rust files (3k loc). +/// +/// +/// Neovim also encountered this problem and reintroduced this limit after it was removed upstream +/// (see and ). +/// The number used here is fundamentally a tradeoff between breaking some obscure edge cases and performance. +/// +/// +/// Neovim chose 64 for this value somewhat arbitrarily (). +/// 64 is too low for some languages though. In particular, it breaks some highlighting for record fields in Erlang record definitions. +/// This number can be increased if new syntax highlight breakages are found, as long as the performance penalty is not too high. +const TREE_SITTER_MATCH_LIMIT: u32 = 256; + impl TextObjectQuery { /// Run the query on the given node and return sub nodes which match given /// capture ("function.inside", "class.around", etc). @@ -394,6 +417,8 @@ impl TextObjectQuery { .iter() .find_map(|cap| self.query.capture_index_for_name(cap))?; + cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT); + let nodes = cursor .captures(&self.query, node, RopeProvider(slice)) .filter_map(move |(mat, _)| { @@ -748,30 +773,38 @@ impl Syntax { // Convert the changeset into tree sitter edits. let edits = generate_edits(old_source, changeset); + // This table allows inverse indexing of `layers`. + // That is by hashing a `Layer` you can find + // the `LayerId` of an existing equivalent `Layer` in `layers`. + // + // It is used to determine if a new layer exists for an injection + // or if an existing layer needs to be updated. + let mut layers_table = RawTable::with_capacity(self.layers.len()); + let layers_hasher = RandomState::new(); // Use the edits to update all layers markers - if !edits.is_empty() { - fn point_add(a: Point, b: Point) -> Point { - if b.row > 0 { - Point::new(a.row.saturating_add(b.row), b.column) - } else { - Point::new(0, a.column.saturating_add(b.column)) - } + fn point_add(a: Point, b: Point) -> Point { + if b.row > 0 { + Point::new(a.row.saturating_add(b.row), b.column) + } else { + Point::new(0, a.column.saturating_add(b.column)) } - fn point_sub(a: Point, b: Point) -> Point { - if a.row > b.row { - Point::new(a.row.saturating_sub(b.row), a.column) - } else { - Point::new(0, a.column.saturating_sub(b.column)) - } + } + fn point_sub(a: Point, b: Point) -> Point { + if a.row > b.row { + Point::new(a.row.saturating_sub(b.row), a.column) + } else { + Point::new(0, a.column.saturating_sub(b.column)) } + } - for layer in self.layers.values_mut() { - // The root layer always covers the whole range (0..usize::MAX) - if layer.depth == 0 { - layer.flags = LayerUpdateFlags::MODIFIED; - continue; - } + for (layer_id, layer) in self.layers.iter_mut() { + // The root layer always covers the whole range (0..usize::MAX) + if layer.depth == 0 { + layer.flags = LayerUpdateFlags::MODIFIED; + continue; + } + if !edits.is_empty() { for range in &mut layer.ranges { // Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720 for edit in edits.iter().rev() { @@ -836,6 +869,12 @@ impl Syntax { } } } + + let hash = layers_hasher.hash_one(layer); + // Safety: insert_no_grow is unsafe because it assumes that the table + // has enough capacity to hold additional elements. + // This is always the case as we reserved enough capacity above. + unsafe { layers_table.insert_no_grow(hash, layer_id) }; } PARSER.with(|ts_parser| { @@ -843,6 +882,7 @@ impl Syntax { let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); // TODO: might need to set cursor range cursor.set_byte_range(0..usize::MAX); + cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT); let source_slice = source.slice(..); @@ -959,27 +999,23 @@ impl Syntax { let depth = layer.depth + 1; // TODO: can't inline this since matches borrows self.layers for (config, ranges) in injections { - // Find an existing layer - let layer = self - .layers - .iter_mut() - .find(|(_, layer)| { - layer.depth == depth && // TODO: track parent id instead - layer.config.language == config.language && layer.ranges == ranges + let new_layer = LanguageLayer { + tree: None, + config, + depth, + ranges, + flags: LayerUpdateFlags::empty(), + }; + + // Find an identical existing layer + let layer = layers_table + .get(layers_hasher.hash_one(&new_layer), |&it| { + self.layers[it] == new_layer }) - .map(|(id, _layer)| id); + .copied(); // ...or insert a new one. - let layer_id = layer.unwrap_or_else(|| { - self.layers.insert(LanguageLayer { - tree: None, - config, - depth, - ranges, - // set the modified flag to ensure the layer is parsed - flags: LayerUpdateFlags::empty(), - }) - }); + let layer_id = layer.unwrap_or_else(|| self.layers.insert(new_layer)); queue.push_back(layer_id); } @@ -1032,6 +1068,7 @@ impl Syntax { // if reusing cursors & no range this resets to whole range cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); + cursor_ref.set_match_limit(TREE_SITTER_MATCH_LIMIT); let mut captures = cursor_ref .captures( @@ -1115,6 +1152,34 @@ pub struct LanguageLayer { flags: LayerUpdateFlags, } +/// This PartialEq implementation only checks if that +/// two layers are theoretically identical (meaning they highlight the same text range with the same language). +/// It does not check whether the layers have the same internal treesitter +/// state. +impl PartialEq for LanguageLayer { + fn eq(&self, other: &Self) -> bool { + self.depth == other.depth + && self.config.language == other.config.language + && self.ranges == other.ranges + } +} + +/// Hash implementation belongs to PartialEq implementation above. +/// See its documentation for details. +impl Hash for LanguageLayer { + fn hash(&self, state: &mut H) { + self.depth.hash(state); + // The transmute is necessary here because tree_sitter::Language does not derive Hash at the moment. + // However it does use #[repr] transparent so the transmute here is safe + // as `Language` (which `Grammar` is an alias for) is just a newtype wrapper around a (thin) pointer. + // This is also compatible with the PartialEq implementation of language + // as that is just a pointer comparison. + let language: *const () = unsafe { transmute(self.config.language) }; + language.hash(state); + self.ranges.hash(state); + } +} + impl LanguageLayer { pub fn tree(&self) -> &Tree { // TODO: no unwrap @@ -1260,7 +1325,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::{iter, mem, ops, str, usize}; use tree_sitter::{ Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, QueryError, - QueryMatch, Range, TextProvider, Tree, + QueryMatch, Range, TextProvider, Tree, TreeCursor, }; const CANCELLATION_CHECK_INTERVAL: usize = 100; @@ -2130,57 +2195,68 @@ impl> Iterator for Merge { } } +fn node_is_visible(node: &Node) -> bool { + node.is_missing() || (node.is_named() && node.language().node_kind_is_visible(node.kind_id())) +} + pub fn pretty_print_tree(fmt: &mut W, node: Node) -> fmt::Result { - pretty_print_tree_impl(fmt, node, true, None, 0) + if node.child_count() == 0 { + if node_is_visible(&node) { + write!(fmt, "({})", node.kind()) + } else { + write!(fmt, "\"{}\"", node.kind()) + } + } else { + pretty_print_tree_impl(fmt, &mut node.walk(), 0) + } } fn pretty_print_tree_impl( fmt: &mut W, - node: Node, - is_root: bool, - field_name: Option<&str>, + cursor: &mut TreeCursor, depth: usize, ) -> fmt::Result { - fn is_visible(node: Node) -> bool { - node.is_missing() - || (node.is_named() && node.language().node_kind_is_visible(node.kind_id())) - } + let node = cursor.node(); + let visible = node_is_visible(&node); - if is_visible(node) { + if visible { let indentation_columns = depth * 2; write!(fmt, "{:indentation_columns$}", "")?; - if let Some(field_name) = field_name { + if let Some(field_name) = cursor.field_name() { write!(fmt, "{}: ", field_name)?; } write!(fmt, "({}", node.kind())?; - } else if is_root { - write!(fmt, "(\"{}\")", node.kind())?; } - for child_idx in 0..node.child_count() { - if let Some(child) = node.child(child_idx) { - if is_visible(child) { + // Handle children. + if cursor.goto_first_child() { + loop { + if node_is_visible(&cursor.node()) { fmt.write_char('\n')?; } - pretty_print_tree_impl( - fmt, - child, - false, - node.field_name_for_child(child_idx as u32), - depth + 1, - )?; + pretty_print_tree_impl(fmt, cursor, depth + 1)?; + + if !cursor.goto_next_sibling() { + break; + } } + + let moved = cursor.goto_parent(); + // The parent of the first child must exist, and must be `node`. + debug_assert!(moved); + debug_assert!(cursor.node() == node); } - if is_visible(node) { - write!(fmt, ")")?; + if visible { + fmt.write_char(')')?; } Ok(()) } + #[cfg(test)] mod test { use super::*; @@ -2353,11 +2429,17 @@ mod test { } #[track_caller] - fn assert_pretty_print(source: &str, expected: &str, start: usize, end: usize) { + fn assert_pretty_print( + language_name: &str, + source: &str, + expected: &str, + start: usize, + end: usize, + ) { let source = Rope::from_str(source); let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language("rust").unwrap(); + let language = get_language(language_name).unwrap(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)); @@ -2377,13 +2459,14 @@ mod test { #[test] fn test_pretty_print() { let source = r#"/// Hello"#; - assert_pretty_print(source, "(line_comment)", 0, source.len()); + assert_pretty_print("rust", source, "(line_comment)", 0, source.len()); // A large tree should be indented with fields: let source = r#"fn main() { println!("Hello, World!"); }"#; assert_pretty_print( + "rust", source, concat!( "(function_item\n", @@ -2402,11 +2485,34 @@ mod test { // Selecting a token should print just that token: let source = r#"fn main() {}"#; - assert_pretty_print(source, r#"("fn")"#, 0, 1); + assert_pretty_print("rust", source, r#""fn""#, 0, 1); // Error nodes are printed as errors: let source = r#"}{"#; - assert_pretty_print(source, "(ERROR)", 0, source.len()); + assert_pretty_print("rust", source, "(ERROR)", 0, source.len()); + + // Fields broken under unnamed nodes are determined correctly. + // In the following source, `object` belongs to the `singleton_method` + // rule but `name` and `body` belong to an unnamed helper `_method_rest`. + // This can cause a bug with a pretty-printing implementation that + // uses `Node::field_name_for_child` to determine field names but is + // fixed when using `TreeCursor::field_name`. + let source = "def self.method_name + true + end"; + assert_pretty_print( + "ruby", + source, + concat!( + "(singleton_method\n", + " object: (self)\n", + " name: (identifier)\n", + " body: (body_statement\n", + " (true)))" + ), + 0, + source.len(), + ); } #[test] diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs index 3e54d2c2..17523ed7 100644 --- a/helix-core/src/test.rs +++ b/helix-core/src/test.rs @@ -148,6 +148,7 @@ pub fn plain(s: &str, selection: Selection) -> String { } #[cfg(test)] +#[allow(clippy::module_inception)] mod test { use super::*; diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index 559e4f66..ae4d95ec 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -579,7 +579,7 @@ impl<'a> Iterator for ChangeIterator<'a> { #[cfg(test)] mod test { use super::*; - use crate::State; + use crate::history::State; #[test] fn composition() { @@ -706,7 +706,10 @@ mod test { #[test] fn optimized_composition() { - let mut state = State::new("".into()); + let mut state = State { + doc: "".into(), + selection: Selection::point(0), + }; let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from("h")); t1.apply(&mut state.doc); state.selection = state.selection.clone().map(t1.changes()); diff --git a/helix-core/tests/data/indent/languages.toml b/helix-core/tests/data/indent/languages.toml index f9cef494..3206f124 100644 --- a/helix-core/tests/data/indent/languages.toml +++ b/helix-core/tests/data/indent/languages.toml @@ -10,4 +10,4 @@ indent = { tab-width = 4, unit = " " } [[grammar]] name = "rust" -source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a360da0a29a19c281d08295a35ecd0544d2da211" } +source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "0431a2c60828731f27491ee9fdefe25e250ce9c9" } diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index ad432d96..41884e73 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -22,6 +22,6 @@ lsp-types = { version = "0.93", features = ["proposed"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" -tokio = { version = "1.21", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio = { version = "1.22", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.11" which = "4.2" diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index af3c4b57..2c2c7c88 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -4,7 +4,6 @@ use crate::{ Call, Error, OffsetEncoding, Result, }; -use anyhow::anyhow; use helix_core::{find_root, ChangeSet, Rope}; use lsp_types as lsp; use serde::Deserialize; @@ -314,6 +313,7 @@ impl Client { String::from("additionalTextEdits"), ], }), + insert_replace_support: Some(true), ..Default::default() }), completion_item_kind: Some(lsp::CompletionItemKindCapability { @@ -545,16 +545,17 @@ impl Client { new_text: &Rope, changes: &ChangeSet, ) -> Option>> { - // figure out what kind of sync the server supports - let capabilities = self.capabilities.get().unwrap(); + // Return early if the server does not support document sync. let sync_capabilities = match capabilities.text_document_sync { - Some(lsp::TextDocumentSyncCapability::Kind(kind)) - | Some(lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { - change: Some(kind), - .. - })) => kind, + Some( + lsp::TextDocumentSyncCapability::Kind(kind) + | lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { + change: Some(kind), + .. + }), + ) => kind, // None | SyncOptions { changes: None } _ => return None, }; @@ -630,8 +631,12 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { - // ) -> Result> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support completion. + capabilities.completion_provider.as_ref()?; + let params = lsp::CompletionParams { text_document_position: lsp::TextDocumentPositionParams { text_document, @@ -646,15 +651,25 @@ impl Client { // lsp::CompletionContext { trigger_kind: , trigger_character: Some(), } }; - self.call::(params) + Some(self.call::(params)) } - pub async fn resolve_completion_item( + pub fn resolve_completion_item( &self, completion_item: lsp::CompletionItem, - ) -> Result { - self.request::(completion_item) - .await + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support resolving completion items. + match capabilities.completion_provider { + Some(lsp::CompletionOptions { + resolve_provider: Some(true), + .. + }) => (), + _ => return None, + } + + Some(self.call::(completion_item)) } pub fn text_document_signature_help( @@ -665,7 +680,7 @@ impl Client { ) -> Option>> { let capabilities = self.capabilities.get().unwrap(); - // Return early if signature help is not supported + // Return early if the server does not support signature help. capabilities.signature_help_provider.as_ref()?; let params = lsp::SignatureHelpParams { @@ -686,7 +701,18 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support hover. + match capabilities.hover_provider { + Some( + lsp::HoverProviderCapability::Simple(true) + | lsp::HoverProviderCapability::Options(_), + ) => (), + _ => return None, + } + let params = lsp::HoverParams { text_document_position_params: lsp::TextDocumentPositionParams { text_document, @@ -696,7 +722,7 @@ impl Client { // lsp::SignatureHelpContext }; - self.call::(params) + Some(self.call::(params)) } // formatting @@ -709,13 +735,11 @@ impl Client { ) -> Option>>> { let capabilities = self.capabilities.get().unwrap(); - // check if we're able to format + // Return early if the server does not support formatting. match capabilities.document_formatting_provider { - Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), - // None | Some(false) + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), _ => return None, }; - // TODO: return err::unavailable so we can fall back to tree sitter formatting // merge FormattingOptions with 'config.format' let config_format = self @@ -750,22 +774,20 @@ impl Client { }) } - pub async fn text_document_range_formatting( + pub fn text_document_range_formatting( &self, text_document: lsp::TextDocumentIdentifier, range: lsp::Range, options: lsp::FormattingOptions, work_done_token: Option, - ) -> anyhow::Result> { + ) -> Option>>> { let capabilities = self.capabilities.get().unwrap(); - // check if we're able to format + // Return early if the server does not support range formatting. match capabilities.document_range_formatting_provider { - Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), - // None | Some(false) - _ => return Ok(Vec::new()), + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), + _ => return None, }; - // TODO: return err::unavailable so we can fall back to tree sitter formatting let params = lsp::DocumentRangeFormattingParams { text_document, @@ -774,11 +796,13 @@ impl Client { work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, }; - let response = self - .request::(params) - .await?; + let request = self.call::(params); - Ok(response.unwrap_or_default()) + Some(async move { + let json = request.await?; + let response: Option> = serde_json::from_value(json)?; + Ok(response.unwrap_or_default()) + }) } pub fn text_document_document_highlight( @@ -786,7 +810,15 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support document highlight. + match capabilities.document_highlight_provider { + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), + _ => return None, + } + let params = lsp::DocumentHighlightParams { text_document_position_params: lsp::TextDocumentPositionParams { text_document, @@ -798,7 +830,7 @@ impl Client { }, }; - self.call::(params) + Some(self.call::(params)) } fn goto_request< @@ -831,8 +863,20 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { - self.goto_request::(text_document, position, work_done_token) + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support goto-definition. + match capabilities.definition_provider { + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), + _ => return None, + } + + Some(self.goto_request::( + text_document, + position, + work_done_token, + )) } pub fn goto_type_definition( @@ -840,12 +884,23 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { - self.goto_request::( + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support goto-type-definition. + match capabilities.type_definition_provider { + Some( + lsp::TypeDefinitionProviderCapability::Simple(true) + | lsp::TypeDefinitionProviderCapability::Options(_), + ) => (), + _ => return None, + } + + Some(self.goto_request::( text_document, position, work_done_token, - ) + )) } pub fn goto_implementation( @@ -853,12 +908,23 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { - self.goto_request::( + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support goto-definition. + match capabilities.implementation_provider { + Some( + lsp::ImplementationProviderCapability::Simple(true) + | lsp::ImplementationProviderCapability::Options(_), + ) => (), + _ => return None, + } + + Some(self.goto_request::( text_document, position, work_done_token, - ) + )) } pub fn goto_reference( @@ -866,7 +932,15 @@ impl Client { text_document: lsp::TextDocumentIdentifier, position: lsp::Position, work_done_token: Option, - ) -> impl Future> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support goto-reference. + match capabilities.references_provider { + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), + _ => return None, + } + let params = lsp::ReferenceParams { text_document_position: lsp::TextDocumentPositionParams { text_document, @@ -881,31 +955,47 @@ impl Client { }, }; - self.call::(params) + Some(self.call::(params)) } pub fn document_symbols( &self, text_document: lsp::TextDocumentIdentifier, - ) -> impl Future> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support document symbols. + match capabilities.document_symbol_provider { + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), + _ => return None, + } + let params = lsp::DocumentSymbolParams { text_document, work_done_progress_params: lsp::WorkDoneProgressParams::default(), partial_result_params: lsp::PartialResultParams::default(), }; - self.call::(params) + Some(self.call::(params)) } // empty string to get all symbols - pub fn workspace_symbols(&self, query: String) -> impl Future> { + pub fn workspace_symbols(&self, query: String) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support workspace symbols. + match capabilities.workspace_symbol_provider { + Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (), + _ => return None, + } + let params = lsp::WorkspaceSymbolParams { query, work_done_progress_params: lsp::WorkDoneProgressParams::default(), partial_result_params: lsp::PartialResultParams::default(), }; - self.call::(params) + Some(self.call::(params)) } pub fn code_actions( @@ -913,7 +1003,18 @@ impl Client { text_document: lsp::TextDocumentIdentifier, range: lsp::Range, context: lsp::CodeActionContext, - ) -> impl Future> { + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support code actions. + match capabilities.code_action_provider { + Some( + lsp::CodeActionProviderCapability::Simple(true) + | lsp::CodeActionProviderCapability::Options(_), + ) => (), + _ => return None, + } + let params = lsp::CodeActionParams { text_document, range, @@ -922,26 +1023,22 @@ impl Client { partial_result_params: lsp::PartialResultParams::default(), }; - self.call::(params) + Some(self.call::(params)) } - pub async fn rename_symbol( + pub fn rename_symbol( &self, text_document: lsp::TextDocumentIdentifier, position: lsp::Position, new_name: String, - ) -> anyhow::Result { + ) -> Option>> { let capabilities = self.capabilities.get().unwrap(); - // check if we're able to rename + // Return early if the language server does not support renaming. match capabilities.rename_provider { Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), // None | Some(false) - _ => { - log::warn!("rename_symbol failed: The server does not support rename"); - let err = "The server does not support rename"; - return Err(anyhow!(err)); - } + _ => return None, }; let params = lsp::RenameParams { @@ -955,11 +1052,21 @@ impl Client { }, }; - let response = self.request::(params).await?; - Ok(response.unwrap_or_default()) + let request = self.call::(params); + + Some(async move { + let json = request.await?; + let response: Option = serde_json::from_value(json)?; + Ok(response.unwrap_or_default()) + }) } - pub fn command(&self, command: lsp::Command) -> impl Future> { + pub fn command(&self, command: lsp::Command) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the language server does not support executing commands. + capabilities.execute_command_provider.as_ref()?; + let params = lsp::ExecuteCommandParams { command: command.command, arguments: command.arguments.unwrap_or_default(), @@ -968,6 +1075,6 @@ impl Client { }, }; - self.call::(params) + Some(self.call::(params)) } } diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 5999feac..e9272970 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -285,6 +285,8 @@ impl MethodCall { pub enum Notification { // we inject this notification to signal the LSP is ready Initialized, + // and this notification to signal that the LSP exited + Exit, PublishDiagnostics(lsp::PublishDiagnosticsParams), ShowMessage(lsp::ShowMessageParams), LogMessage(lsp::LogMessageParams), @@ -297,6 +299,7 @@ impl Notification { let notification = match method { lsp::notification::Initialized::METHOD => Self::Initialized, + lsp::notification::Exit::METHOD => Self::Exit, lsp::notification::PublishDiagnostics::METHOD => { let params: lsp::PublishDiagnosticsParams = params.parse()?; Self::PublishDiagnostics(params) @@ -353,7 +356,11 @@ impl Registry { .map(|(_, client)| client.as_ref()) } - pub fn get( + pub fn remove_by_id(&mut self, id: usize) { + self.inner.retain(|_, (client_id, _)| client_id != &id) + } + + pub fn restart( &mut self, language_config: &LanguageConfiguration, doc_path: Option<&std::path::PathBuf>, @@ -363,9 +370,11 @@ impl Registry { None => return Ok(None), }; - match self.inner.entry(language_config.scope.clone()) { - Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())), - Entry::Vacant(entry) => { + let scope = language_config.scope.clone(); + + match self.inner.entry(scope) { + Entry::Vacant(_) => Ok(None), + Entry::Occupied(mut entry) => { // initialize a new client let id = self.counter.fetch_add(1, Ordering::Relaxed); @@ -373,83 +382,41 @@ impl Registry { start_client(id, language_config, config, doc_path)?; self.incoming.push(UnboundedReceiverStream::new(incoming)); - entry.insert((id, client.clone())); + let (_, old_client) = entry.insert((id, client.clone())); + + tokio::spawn(async move { + let _ = old_client.force_shutdown().await; + }); + Ok(Some(client)) } } } - pub fn restart( - &mut self, - language_config: &LanguageConfiguration, - path: Option<&PathBuf>, - ) -> Result> { - let config = language_config - .language_server - .as_ref() - .ok_or(Error::LspNotDefined)?; - let id = self - .inner - .get(&language_config.scope) - .ok_or(Error::LspNotDefined)? - .0; - let new_client = self.initialize_client(language_config, config, id, path)?; - let (_, client) = self - .inner - .get_mut(&language_config.scope) - .ok_or(Error::LspNotDefined)?; - *client = new_client; - - Ok(client.clone()) - } - - fn initialize_client( + pub fn get( &mut self, language_config: &LanguageConfiguration, - config: &helix_core::syntax::LanguageServerConfiguration, - id: usize, - path: Option<&PathBuf>, - ) -> Result> { - let (client, incoming, initialize_notify) = Client::start( - &config.command, - &config.args, - language_config.config.clone(), - &language_config.roots, - id, - config.timeout, - path, - )?; - self.incoming.push(UnboundedReceiverStream::new(incoming)); - let client = Arc::new(client); - - // Initialize the client asynchronously - let _client = client.clone(); - tokio::spawn(async move { - use futures_util::TryFutureExt; - let value = _client - .capabilities - .get_or_try_init(|| { - _client - .initialize() - .map_ok(|response| response.capabilities) - }) - .await; - - if let Err(e) = value { - log::error!("failed to initialize language server: {}", e); - return; - } + doc_path: Option<&std::path::PathBuf>, + ) -> Result>> { + let config = match &language_config.language_server { + Some(config) => config, + None => return Ok(None), + }; - // next up, notify - _client - .notify::(lsp::InitializedParams {}) - .await - .unwrap(); + match self.inner.entry(language_config.scope.clone()) { + Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())), + Entry::Vacant(entry) => { + // initialize a new client + let id = self.counter.fetch_add(1, Ordering::Relaxed); - initialize_notify.notify_one(); - }); + let NewClientResult(client, incoming) = + start_client(id, language_config, config, doc_path)?; + self.incoming.push(UnboundedReceiverStream::new(incoming)); - Ok(client) + entry.insert((id, client.clone())); + Ok(Some(client)) + } + } } pub fn iter_clients(&self) -> impl Iterator> { diff --git a/helix-lsp/src/lib.rs.orig b/helix-lsp/src/lib.rs.orig new file mode 100644 index 00000000..c76ca59e --- /dev/null +++ b/helix-lsp/src/lib.rs.orig @@ -0,0 +1,696 @@ +mod client; +pub mod jsonrpc; +mod transport; + +pub use client::Client; +pub use futures_executor::block_on; +pub use jsonrpc::Call; +pub use lsp::{Position, Url}; +pub use lsp_types as lsp; + +use futures_util::stream::select_all::SelectAll; +use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration}; +use tokio::sync::mpsc::UnboundedReceiver; + +use std::{ + collections::{hash_map::Entry, HashMap}, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, +}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio_stream::wrappers::UnboundedReceiverStream; + +pub type Result = core::result::Result; +type LanguageId = String; + +#[derive(Error, Debug)] +pub enum Error { + #[error("protocol error: {0}")] + Rpc(#[from] jsonrpc::Error), + #[error("failed to parse: {0}")] + Parse(#[from] serde_json::Error), + #[error("IO Error: {0}")] + IO(#[from] std::io::Error), + #[error("request timed out")] + Timeout, + #[error("server closed the stream")] + StreamClosed, + #[error("LPS not defined")] + LspNotDefined, + #[error("Unhandled")] + Unhandled, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum OffsetEncoding { + /// UTF-8 code units aka bytes + #[serde(rename = "utf-8")] + Utf8, + /// UTF-16 code units + #[serde(rename = "utf-16")] + Utf16, +} + +pub mod util { + use super::*; + use helix_core::{diagnostic::NumberOrString, Range, Rope, Transaction}; + + /// Converts a diagnostic in the document to [`lsp::Diagnostic`]. + /// + /// Panics when [`pos_to_lsp_pos`] would for an invalid range on the diagnostic. + pub fn diagnostic_to_lsp_diagnostic( + doc: &Rope, + diag: &helix_core::diagnostic::Diagnostic, + offset_encoding: OffsetEncoding, + ) -> lsp::Diagnostic { + use helix_core::diagnostic::Severity::*; + + let range = Range::new(diag.range.start, diag.range.end); + let severity = diag.severity.map(|s| match s { + Hint => lsp::DiagnosticSeverity::HINT, + Info => lsp::DiagnosticSeverity::INFORMATION, + Warning => lsp::DiagnosticSeverity::WARNING, + Error => lsp::DiagnosticSeverity::ERROR, + }); + + let code = match diag.code.clone() { + Some(x) => match x { + NumberOrString::Number(x) => Some(lsp::NumberOrString::Number(x)), + NumberOrString::String(x) => Some(lsp::NumberOrString::String(x)), + }, + None => None, + }; + + let new_tags: Vec<_> = diag + .tags + .iter() + .map(|tag| match tag { + helix_core::diagnostic::DiagnosticTag::Unnecessary => { + lsp::DiagnosticTag::UNNECESSARY + } + helix_core::diagnostic::DiagnosticTag::Deprecated => lsp::DiagnosticTag::DEPRECATED, + }) + .collect(); + + let tags = if !new_tags.is_empty() { + Some(new_tags) + } else { + None + }; + + // TODO: add support for Diagnostic.data + lsp::Diagnostic::new( + range_to_lsp_range(doc, range, offset_encoding), + severity, + code, + diag.source.clone(), + diag.message.to_owned(), + None, + tags, + ) + } + + /// Converts [`lsp::Position`] to a position in the document. + /// + /// Returns `None` if position exceeds document length or an operation overflows. + pub fn lsp_pos_to_pos( + doc: &Rope, + pos: lsp::Position, + offset_encoding: OffsetEncoding, + ) -> Option { + let pos_line = pos.line as usize; + if pos_line > doc.len_lines() - 1 { + return None; + } + + match offset_encoding { + OffsetEncoding::Utf8 => { + let line = doc.line_to_char(pos_line); + let pos = line.checked_add(pos.character as usize)?; + if pos <= doc.len_chars() { + Some(pos) + } else { + None + } + } + OffsetEncoding::Utf16 => { + let line = doc.line_to_char(pos_line); + let line_start = doc.char_to_utf16_cu(line); + let pos = line_start.checked_add(pos.character as usize)?; + doc.try_utf16_cu_to_char(pos).ok() + } + } + } + + /// Converts position in the document to [`lsp::Position`]. + /// + /// Panics when `pos` is out of `doc` bounds or operation overflows. + pub fn pos_to_lsp_pos( + doc: &Rope, + pos: usize, + offset_encoding: OffsetEncoding, + ) -> lsp::Position { + match offset_encoding { + OffsetEncoding::Utf8 => { + let line = doc.char_to_line(pos); + let line_start = doc.line_to_char(line); + let col = pos - line_start; + + lsp::Position::new(line as u32, col as u32) + } + OffsetEncoding::Utf16 => { + let line = doc.char_to_line(pos); + let line_start = doc.char_to_utf16_cu(doc.line_to_char(line)); + let col = doc.char_to_utf16_cu(pos) - line_start; + + lsp::Position::new(line as u32, col as u32) + } + } + } + + /// Converts a range in the document to [`lsp::Range`]. + pub fn range_to_lsp_range( + doc: &Rope, + range: Range, + offset_encoding: OffsetEncoding, + ) -> lsp::Range { + let start = pos_to_lsp_pos(doc, range.from(), offset_encoding); + let end = pos_to_lsp_pos(doc, range.to(), offset_encoding); + + lsp::Range::new(start, end) + } + + pub fn lsp_range_to_range( + doc: &Rope, + range: lsp::Range, + offset_encoding: OffsetEncoding, + ) -> Option { + let start = lsp_pos_to_pos(doc, range.start, offset_encoding)?; + let end = lsp_pos_to_pos(doc, range.end, offset_encoding)?; + + Some(Range::new(start, end)) + } + + pub fn generate_transaction_from_edits( + doc: &Rope, + mut edits: Vec, + offset_encoding: OffsetEncoding, + ) -> Transaction { + // Sort edits by start range, since some LSPs (Omnisharp) send them + // in reverse order. + edits.sort_unstable_by_key(|edit| edit.range.start); + + // Generate a diff if the edit is a full document replacement. + #[allow(clippy::collapsible_if)] + if edits.len() == 1 { + let is_document_replacement = edits.first().and_then(|edit| { + let start = lsp_pos_to_pos(doc, edit.range.start, offset_encoding)?; + let end = lsp_pos_to_pos(doc, edit.range.end, offset_encoding)?; + Some(start..end) + }) == Some(0..doc.len_chars()); + if is_document_replacement { + let new_text = Rope::from(edits.pop().unwrap().new_text); + return helix_core::diff::compare_ropes(doc, &new_text); + } + } + + Transaction::change( + doc, + edits.into_iter().map(|edit| { + // simplify "" into None for cleaner changesets + let replacement = if !edit.new_text.is_empty() { + Some(edit.new_text.into()) + } else { + None + }; + + let start = + if let Some(start) = lsp_pos_to_pos(doc, edit.range.start, offset_encoding) { + start + } else { + return (0, 0, None); + }; + let end = if let Some(end) = lsp_pos_to_pos(doc, edit.range.end, offset_encoding) { + end + } else { + return (0, 0, None); + }; + (start, end, replacement) + }), + ) + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum MethodCall { + WorkDoneProgressCreate(lsp::WorkDoneProgressCreateParams), + ApplyWorkspaceEdit(lsp::ApplyWorkspaceEditParams), + WorkspaceFolders, + WorkspaceConfiguration(lsp::ConfigurationParams), +} + +impl MethodCall { + pub fn parse(method: &str, params: jsonrpc::Params) -> Result { + use lsp::request::Request; + let request = match method { + lsp::request::WorkDoneProgressCreate::METHOD => { + let params: lsp::WorkDoneProgressCreateParams = params.parse()?; + Self::WorkDoneProgressCreate(params) + } + lsp::request::ApplyWorkspaceEdit::METHOD => { + let params: lsp::ApplyWorkspaceEditParams = params.parse()?; + Self::ApplyWorkspaceEdit(params) + } + lsp::request::WorkspaceFoldersRequest::METHOD => Self::WorkspaceFolders, + lsp::request::WorkspaceConfiguration::METHOD => { + let params: lsp::ConfigurationParams = params.parse()?; + Self::WorkspaceConfiguration(params) + } + _ => { + return Err(Error::Unhandled); + } + }; + Ok(request) + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum Notification { + // we inject this notification to signal the LSP is ready + Initialized, + // and this notification to signal that the LSP exited + Exit, + PublishDiagnostics(lsp::PublishDiagnosticsParams), + ShowMessage(lsp::ShowMessageParams), + LogMessage(lsp::LogMessageParams), + ProgressMessage(lsp::ProgressParams), +} + +impl Notification { + pub fn parse(method: &str, params: jsonrpc::Params) -> Result { + use lsp::notification::Notification as _; + + let notification = match method { + lsp::notification::Initialized::METHOD => Self::Initialized, + lsp::notification::Exit::METHOD => Self::Exit, + lsp::notification::PublishDiagnostics::METHOD => { + let params: lsp::PublishDiagnosticsParams = params.parse()?; + Self::PublishDiagnostics(params) + } + + lsp::notification::ShowMessage::METHOD => { + let params: lsp::ShowMessageParams = params.parse()?; + Self::ShowMessage(params) + } + lsp::notification::LogMessage::METHOD => { + let params: lsp::LogMessageParams = params.parse()?; + Self::LogMessage(params) + } + lsp::notification::Progress::METHOD => { + let params: lsp::ProgressParams = params.parse()?; + Self::ProgressMessage(params) + } + _ => { + return Err(Error::Unhandled); + } + }; + + Ok(notification) + } +} + +#[derive(Debug)] +pub struct Registry { + inner: HashMap)>, + + counter: AtomicUsize, + pub incoming: SelectAll>, +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +impl Registry { + pub fn new() -> Self { + Self { + inner: HashMap::new(), + counter: AtomicUsize::new(0), + incoming: SelectAll::new(), + } + } + + pub fn get_by_id(&self, id: usize) -> Option<&Client> { + self.inner + .values() + .find(|(client_id, _)| client_id == &id) + .map(|(_, client)| client.as_ref()) + } + +<<<<<<< HEAD +||||||| 4ec2a21c + pub fn restart( + &mut self, + language_config: &LanguageConfiguration, + doc_path: Option<&std::path::PathBuf>, + ) -> Result>> { + let config = match &language_config.language_server { + Some(config) => config, + None => return Ok(None), + }; + + let scope = language_config.scope.clone(); + + match self.inner.entry(scope) { + Entry::Vacant(_) => Ok(None), + Entry::Occupied(mut entry) => { + // initialize a new client + let id = self.counter.fetch_add(1, Ordering::Relaxed); + + let NewClientResult(client, incoming) = + start_client(id, language_config, config, doc_path)?; + self.incoming.push(UnboundedReceiverStream::new(incoming)); + + let (_, old_client) = entry.insert((id, client.clone())); + + tokio::spawn(async move { + let _ = old_client.force_shutdown().await; + }); + + Ok(Some(client)) + } + } + } + +======= + pub fn remove_by_id(&mut self, id: usize) { + self.inner.retain(|_, (client_id, _)| client_id != &id) + } + + pub fn restart( + &mut self, + language_config: &LanguageConfiguration, + doc_path: Option<&std::path::PathBuf>, + ) -> Result>> { + let config = match &language_config.language_server { + Some(config) => config, + None => return Ok(None), + }; + + let scope = language_config.scope.clone(); + + match self.inner.entry(scope) { + Entry::Vacant(_) => Ok(None), + Entry::Occupied(mut entry) => { + // initialize a new client + let id = self.counter.fetch_add(1, Ordering::Relaxed); + + let NewClientResult(client, incoming) = + start_client(id, language_config, config, doc_path)?; + self.incoming.push(UnboundedReceiverStream::new(incoming)); + + let (_, old_client) = entry.insert((id, client.clone())); + + tokio::spawn(async move { + let _ = old_client.force_shutdown().await; + }); + + Ok(Some(client)) + } + } + } + +>>>>>>> master + pub fn get( + &mut self, + language_config: &LanguageConfiguration, + doc_path: Option<&std::path::PathBuf>, + ) -> Result>> { + let config = match &language_config.language_server { + Some(config) => config, + None => return Ok(None), + }; + + match self.inner.entry(language_config.scope.clone()) { + Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())), + Entry::Vacant(entry) => { + // initialize a new client + let id = self.counter.fetch_add(1, Ordering::Relaxed); + + let NewClientResult(client, incoming) = + start_client(id, language_config, config, doc_path)?; + self.incoming.push(UnboundedReceiverStream::new(incoming)); + + entry.insert((id, client.clone())); + Ok(Some(client)) + } + } + } + + pub fn restart( + &mut self, + language_config: &LanguageConfiguration, + path: Option<&PathBuf>, + ) -> Result> { + let config = language_config + .language_server + .as_ref() + .ok_or(Error::LspNotDefined)?; + let id = self + .inner + .get(&language_config.scope) + .ok_or(Error::LspNotDefined)? + .0; + let new_client = self.initialize_client(language_config, config, id, path)?; + let (_, client) = self + .inner + .get_mut(&language_config.scope) + .ok_or(Error::LspNotDefined)?; + *client = new_client; + + Ok(client.clone()) + } + + fn initialize_client( + &mut self, + language_config: &LanguageConfiguration, + config: &helix_core::syntax::LanguageServerConfiguration, + id: usize, + path: Option<&PathBuf>, + ) -> Result> { + let (client, incoming, initialize_notify) = Client::start( + &config.command, + &config.args, + language_config.config.clone(), + &language_config.roots, + id, + config.timeout, + path, + )?; + self.incoming.push(UnboundedReceiverStream::new(incoming)); + let client = Arc::new(client); + + // Initialize the client asynchronously + let _client = client.clone(); + tokio::spawn(async move { + use futures_util::TryFutureExt; + let value = _client + .capabilities + .get_or_try_init(|| { + _client + .initialize() + .map_ok(|response| response.capabilities) + }) + .await; + + if let Err(e) = value { + log::error!("failed to initialize language server: {}", e); + return; + } + + // next up, notify + _client + .notify::(lsp::InitializedParams {}) + .await + .unwrap(); + + initialize_notify.notify_one(); + }); + + Ok(client) + } + + pub fn iter_clients(&self) -> impl Iterator> { + self.inner.values().map(|(_, client)| client) + } +} + +#[derive(Debug)] +pub enum ProgressStatus { + Created, + Started(lsp::WorkDoneProgress), +} + +impl ProgressStatus { + pub fn progress(&self) -> Option<&lsp::WorkDoneProgress> { + match &self { + ProgressStatus::Created => None, + ProgressStatus::Started(progress) => Some(progress), + } + } +} + +#[derive(Default, Debug)] +/// Acts as a container for progress reported by language servers. Each server +/// has a unique id assigned at creation through [`Registry`]. This id is then used +/// to store the progress in this map. +pub struct LspProgressMap(HashMap>); + +impl LspProgressMap { + pub fn new() -> Self { + Self::default() + } + + /// Returns a map of all tokens corresponding to the language server with `id`. + pub fn progress_map(&self, id: usize) -> Option<&HashMap> { + self.0.get(&id) + } + + pub fn is_progressing(&self, id: usize) -> bool { + self.0.get(&id).map(|it| !it.is_empty()).unwrap_or_default() + } + + /// Returns last progress status for a given server with `id` and `token`. + pub fn progress(&self, id: usize, token: &lsp::ProgressToken) -> Option<&ProgressStatus> { + self.0.get(&id).and_then(|values| values.get(token)) + } + + /// Checks if progress `token` for server with `id` is created. + pub fn is_created(&mut self, id: usize, token: &lsp::ProgressToken) -> bool { + self.0 + .get(&id) + .map(|values| values.get(token).is_some()) + .unwrap_or_default() + } + + pub fn create(&mut self, id: usize, token: lsp::ProgressToken) { + self.0 + .entry(id) + .or_default() + .insert(token, ProgressStatus::Created); + } + + /// Ends the progress by removing the `token` from server with `id`, if removed returns the value. + pub fn end_progress( + &mut self, + id: usize, + token: &lsp::ProgressToken, + ) -> Option { + self.0.get_mut(&id).and_then(|vals| vals.remove(token)) + } + + /// Updates the progress of `token` for server with `id` to `status`, returns the value replaced or `None`. + pub fn update( + &mut self, + id: usize, + token: lsp::ProgressToken, + status: lsp::WorkDoneProgress, + ) -> Option { + self.0 + .entry(id) + .or_default() + .insert(token, ProgressStatus::Started(status)) + } +} + +struct NewClientResult(Arc, UnboundedReceiver<(usize, Call)>); + +/// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that +/// it is only called when it makes sense. +fn start_client( + id: usize, + config: &LanguageConfiguration, + ls_config: &LanguageServerConfiguration, + doc_path: Option<&std::path::PathBuf>, +) -> Result { + let (client, incoming, initialize_notify) = Client::start( + &ls_config.command, + &ls_config.args, + config.config.clone(), + &config.roots, + id, + ls_config.timeout, + doc_path, + )?; + + let client = Arc::new(client); + + // Initialize the client asynchronously + let _client = client.clone(); + tokio::spawn(async move { + use futures_util::TryFutureExt; + let value = _client + .capabilities + .get_or_try_init(|| { + _client + .initialize() + .map_ok(|response| response.capabilities) + }) + .await; + + if let Err(e) = value { + log::error!("failed to initialize language server: {}", e); + return; + } + + // next up, notify + _client + .notify::(lsp::InitializedParams {}) + .await + .unwrap(); + + initialize_notify.notify_one(); + }); + + Ok(NewClientResult(client, incoming)) +} + +#[cfg(test)] +mod tests { + use super::{lsp, util::*, OffsetEncoding}; + use helix_core::Rope; + + #[test] + fn converts_lsp_pos_to_pos() { + macro_rules! test_case { + ($doc:expr, ($x:expr, $y:expr) => $want:expr) => { + let doc = Rope::from($doc); + let pos = lsp::Position::new($x, $y); + assert_eq!($want, lsp_pos_to_pos(&doc, pos, OffsetEncoding::Utf16)); + assert_eq!($want, lsp_pos_to_pos(&doc, pos, OffsetEncoding::Utf8)) + }; + } + + test_case!("", (0, 0) => Some(0)); + test_case!("", (0, 1) => None); + test_case!("", (1, 0) => None); + test_case!("\n\n", (0, 0) => Some(0)); + test_case!("\n\n", (1, 0) => Some(1)); + test_case!("\n\n", (1, 1) => Some(2)); + test_case!("\n\n", (2, 0) => Some(2)); + test_case!("\n\n", (3, 0) => None); + test_case!("test\n\n\n\ncase", (4, 3) => Some(11)); + test_case!("test\n\n\n\ncase", (4, 4) => Some(12)); + test_case!("test\n\n\n\ncase", (4, 5) => None); + test_case!("", (u32::MAX, u32::MAX) => None); + } +} diff --git a/helix-lsp/src/transport.rs b/helix-lsp/src/transport.rs index 8aaeae3d..3e3e06ee 100644 --- a/helix-lsp/src/transport.rs +++ b/helix-lsp/src/transport.rs @@ -250,6 +250,36 @@ impl Transport { } }; } + Err(Error::StreamClosed) => { + // Close any outstanding requests. + for (id, tx) in transport.pending_requests.lock().await.drain() { + match tx.send(Err(Error::StreamClosed)).await { + Ok(_) => (), + Err(_) => { + error!("Could not close request on a closed channel (id={:?})", id) + } + } + } + + // Hack: inject a terminated notification so we trigger code that needs to happen after exit + use lsp_types::notification::Notification as _; + let notification = + ServerMessage::Call(jsonrpc::Call::Notification(jsonrpc::Notification { + jsonrpc: None, + method: lsp_types::notification::Exit::METHOD.to_string(), + params: jsonrpc::Params::None, + })); + match transport + .process_server_message(&client_tx, notification) + .await + { + Ok(_) => {} + Err(err) => { + error!("err: <- {:?}", err); + } + } + break; + } Err(err) => { error!("err: <- {:?}", err); break; diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index feb6c0c7..43ef0e01 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -10,11 +10,13 @@ use helix_view::{ align_view, document::DocumentSavedEventResult, editor::{ConfigEvent, EditorEvent}, + graphics::Rect, theme, tree::Layout, Align, Editor, }; use serde_json::json; +use tui::backend::Backend; use crate::{ args::Args, @@ -53,8 +55,21 @@ type Signals = futures_util::stream::Empty<()>; const LSP_DEADLINE: Duration = Duration::from_millis(16); +#[cfg(not(feature = "integration"))] +use tui::backend::CrosstermBackend; + +#[cfg(feature = "integration")] +use tui::backend::TestBackend; + +#[cfg(not(feature = "integration"))] +type Terminal = tui::terminal::Terminal>; + +#[cfg(feature = "integration")] +type Terminal = tui::terminal::Terminal; + pub struct Application { compositor: Compositor, + terminal: Terminal, pub editor: Editor, config: Arc>, @@ -143,10 +158,18 @@ impl Application { let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); - let mut compositor = Compositor::new().context("build compositor")?; + #[cfg(not(feature = "integration"))] + let backend = CrosstermBackend::new(stdout()); + + #[cfg(feature = "integration")] + let backend = TestBackend::new(120, 150); + + let terminal = Terminal::new(backend)?; + let area = terminal.size().expect("couldn't get terminal size"); + let mut compositor = Compositor::new(area); let config = Arc::new(ArcSwap::from_pointee(config)); let mut editor = Editor::new( - compositor.size(), + area, theme_loader.clone(), syn_loader.clone(), Box::new(Map::new(Arc::clone(&config), |config: &Config| { @@ -245,6 +268,7 @@ impl Application { let app = Self { compositor, + terminal, editor, config, @@ -266,15 +290,26 @@ impl Application { #[cfg(not(feature = "integration"))] fn render(&mut self) { - let compositor = &mut self.compositor; - let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - compositor.render(&mut cx); + let area = self + .terminal + .autoresize() + .expect("Unable to determine terminal size"); + + // TODO: need to recalculate view tree if necessary + + let surface = self.terminal.current_buffer_mut(); + + self.compositor.render(area, surface, &mut cx); + + let (pos, kind) = self.compositor.cursor(area, &self.editor); + let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); + self.terminal.draw(pos, kind).unwrap(); } pub async fn event_loop(&mut self, input_stream: &mut S) @@ -404,19 +439,24 @@ impl Application { #[cfg(not(windows))] pub async fn handle_signals(&mut self, signal: i32) { - use helix_view::graphics::Rect; match signal { signal::SIGTSTP => { - self.compositor.save_cursor(); + // restore cursor + use helix_view::graphics::CursorKind; + self.terminal + .backend_mut() + .show_cursor(CursorKind::Block) + .ok(); restore_term().unwrap(); low_level::emulate_default_handler(signal::SIGTSTP).unwrap(); } signal::SIGCONT => { self.claim_term().await.unwrap(); // redraw the terminal - let Rect { width, height, .. } = self.compositor.size(); - self.compositor.resize(width, height); - self.compositor.load_cursor(); + let area = self.terminal.size().expect("couldn't get terminal size"); + self.compositor.resize(area); + self.terminal.clear().expect("couldn't clear terminal"); + self.render(); } signal::SIGUSR1 => { @@ -553,7 +593,14 @@ impl Application { // Handle key events let should_redraw = match event.unwrap() { CrosstermEvent::Resize(width, height) => { - self.compositor.resize(width, height); + self.terminal + .resize(Rect::new(0, 0, width, height)) + .expect("Unable to resize terminal"); + + let area = self.terminal.size().expect("couldn't get terminal size"); + + self.compositor.resize(area); + self.compositor .handle_event(&Event::Resize(width, height), &mut cx) } @@ -836,6 +883,32 @@ impl Application { Notification::ProgressMessage(_params) => { // do nothing } + Notification::Exit => { + self.editor.set_status("Language server exited"); + + // Clear any diagnostics for documents with this server open. + let urls: Vec<_> = self + .editor + .documents_mut() + .filter_map(|doc| { + if doc.language_server().map(|server| server.id()) + == Some(server_id) + { + doc.set_diagnostics(Vec::new()); + doc.url() + } else { + None + } + }) + .collect(); + + for url in urls { + self.editor.diagnostics.remove(&url); + } + + // Remove the language server from the registry. + self.editor.language_servers.remove_by_id(server_id); + } } } Call::MethodCall(helix_lsp::jsonrpc::MethodCall { @@ -936,7 +1009,11 @@ impl Application { } async fn claim_term(&mut self) -> Result<(), Error> { + use helix_view::graphics::CursorKind; terminal::enable_raw_mode()?; + if self.terminal.cursor_kind() == CursorKind::Hidden { + self.terminal.backend_mut().hide_cursor().ok(); + } let mut stdout = stdout(); execute!( stdout, @@ -970,6 +1047,13 @@ impl Application { self.event_loop(input_stream).await; let close_errs = self.close().await; + + // restore cursor + use helix_view::graphics::CursorKind; + self.terminal + .backend_mut() + .show_cursor(CursorKind::Block) + .ok(); restore_term()?; for err in close_errs { diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 8882b8d6..d8ae9d64 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -54,7 +54,7 @@ use crate::{ use crate::job::{self, Jobs}; use futures_util::StreamExt; -use std::{collections::HashMap, fmt, fmt::Write, future::Future}; +use std::{collections::HashMap, fmt, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; use std::{ @@ -210,17 +210,18 @@ impl MappableCommand { copy_selection_on_prev_line, "Copy selection on previous line", move_next_word_start, "Move to start of next word", move_prev_word_start, "Move to start of previous word", - move_prev_word_end, "Move to end of previous word", move_next_word_end, "Move to end of next word", + move_prev_word_end, "Move to end of previous word", move_next_long_word_start, "Move to start of next long word", move_prev_long_word_start, "Move to start of previous long word", move_next_long_word_end, "Move to end of next long word", extend_next_word_start, "Extend to start of next word", extend_prev_word_start, "Extend to start of previous word", + extend_next_word_end, "Extend to end of next word", + extend_prev_word_end, "Extend to end of previous word", extend_next_long_word_start, "Extend to start of next long word", extend_prev_long_word_start, "Extend to start of previous long word", extend_next_long_word_end, "Extend to end of next long word", - extend_next_word_end, "Extend to end of next word", find_till_char, "Move till next occurrence of char", find_next_char, "Move to next occurrence of char", extend_till_char, "Extend till next occurrence of char", @@ -249,6 +250,7 @@ impl MappableCommand { extend_search_next, "Add next search match to selection", extend_search_prev, "Add previous search match to selection", search_selection, "Use current selection as search pattern", + make_search_word_bounded, "Modify current search to make it word bounded", global_search, "Global search in workspace folder", extend_line, "Select current line, if already selected, extend to another line based on the anchor", extend_line_below, "Select current line, if already selected, extend to next line", @@ -311,8 +313,7 @@ impl MappableCommand { goto_line_end, "Goto line end", goto_next_buffer, "Goto next buffer", goto_previous_buffer, "Goto previous buffer", - // TODO: different description ? - goto_line_end_newline, "Goto line end", + goto_line_end_newline, "Goto newline at line end", goto_first_nonwhitespace, "Goto first non-blank in line", trim_selections, "Trim whitespace from selections", extend_to_line_start, "Extend to line start", @@ -783,11 +784,7 @@ fn trim_selections(cx: &mut Context) { let mut end = range.to(); start = movement::skip_while(text, start, |x| x.is_whitespace()).unwrap_or(start); end = movement::backwards_skip_while(text, end, |x| x.is_whitespace()).unwrap_or(end); - if range.anchor < range.head { - Some(Range::new(start, end)) - } else { - Some(Range::new(end, start)) - } + Some(Range::new(start, end).with_direction(range.direction())) }) .collect(); @@ -874,7 +871,7 @@ fn goto_window(cx: &mut Context, align: Align) { let config = cx.editor.config(); let (view, doc) = current!(cx.editor); - let height = view.inner_area().height as usize; + let height = view.inner_height(); // respect user given count if any // - 1 so we have at least one gap in the middle. @@ -1097,6 +1094,10 @@ fn extend_next_word_end(cx: &mut Context) { extend_word_impl(cx, movement::move_next_word_end) } +fn extend_prev_word_end(cx: &mut Context) { + extend_word_impl(cx, movement::move_prev_word_end) +} + fn extend_next_long_word_start(cx: &mut Context) { extend_word_impl(cx, movement::move_next_long_word_start) } @@ -1134,6 +1135,10 @@ where doc!(cx.editor).line_ending.as_str().chars().next().unwrap() } + KeyEvent { + code: KeyCode::Tab, .. + } => '\t', + KeyEvent { code: KeyCode::Char(ch), .. @@ -1280,6 +1285,9 @@ fn replace(cx: &mut Context) { code: KeyCode::Enter, .. } => Some(doc.line_ending.as_str()), + KeyEvent { + code: KeyCode::Tab, .. + } => Some("\t"), _ => None, }; @@ -1376,9 +1384,9 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { return; } - let height = view.inner_area().height; + let height = view.inner_height(); - let scrolloff = config.scrolloff.min(height as usize / 2); + let scrolloff = config.scrolloff.min(height / 2); view.offset.row = match direction { Forward => view.offset.row + offset, @@ -1416,25 +1424,25 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { fn page_up(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.inner_area().height as usize; + let offset = view.inner_height(); scroll(cx, offset, Direction::Backward); } fn page_down(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.inner_area().height as usize; + let offset = view.inner_height(); scroll(cx, offset, Direction::Forward); } fn half_page_up(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.inner_area().height as usize / 2; + let offset = view.inner_height() / 2; scroll(cx, offset, Direction::Backward); } fn half_page_down(cx: &mut Context) { let view = view!(cx.editor); - let offset = view.inner_area().height as usize / 2; + let offset = view.inner_height() / 2; scroll(cx, offset, Direction::Forward); } @@ -1655,11 +1663,7 @@ fn search_impl( // Determine range direction based on the primary range let primary = selection.primary(); - let range = if primary.head < primary.anchor { - Range::new(end, start) - } else { - Range::new(start, end) - }; + let range = Range::new(start, end).with_direction(primary.direction()); let selection = match movement { Movement::Extend => selection.clone().push(range), @@ -1805,7 +1809,36 @@ fn search_selection(cx: &mut Context) { .join("|"); let msg = format!("register '{}' set to '{}'", '/', ®ex); - cx.editor.registers.get_mut('/').push(regex); + cx.editor.registers.push('/', regex); + cx.editor.set_status(msg); +} + +fn make_search_word_bounded(cx: &mut Context) { + let regex = match cx.editor.registers.last('/') { + Some(regex) => regex, + None => return, + }; + let start_anchored = regex.starts_with("\\b"); + let end_anchored = regex.ends_with("\\b"); + + if start_anchored && end_anchored { + return; + } + + let mut new_regex = String::with_capacity( + regex.len() + if start_anchored { 0 } else { 2 } + if end_anchored { 0 } else { 2 }, + ); + + if !start_anchored { + new_regex.push_str("\\b"); + } + new_regex.push_str(regex); + if !end_anchored { + new_regex.push_str("\\b"); + } + + let msg = format!("register '{}' set to '{}'", '/', &new_regex); + cx.editor.registers.push('/', new_regex); cx.editor.set_status(msg); } @@ -1976,7 +2009,7 @@ fn global_search(cx: &mut Context) { align_view(doc, view, Align::Center); }, |_editor, FileResult { path, line_num }| { - Some((path.clone(), Some((*line_num, *line_num)))) + Some((path.clone().into(), Some((*line_num, *line_num)))) }, ); compositor.push(Box::new(overlayed(picker))); @@ -2063,11 +2096,7 @@ fn extend_to_line_bounds(cx: &mut Context) { let start = text.line_to_char(start_line); let end = text.line_to_char((end_line + 1).min(text.len_lines())); - if range.anchor <= range.head { - Range::new(start, end) - } else { - Range::new(end, start) - } + Range::new(start, end).with_direction(range.direction()) }), ); } @@ -2104,11 +2133,7 @@ fn shrink_to_line_bounds(cx: &mut Context) { end = text.line_to_char(end_line); } - if range.anchor <= range.head { - Range::new(start, end) - } else { - Range::new(end, start) - } + Range::new(start, end).with_direction(range.direction()) }), ); } @@ -2121,16 +2146,14 @@ enum Operation { fn delete_selection_impl(cx: &mut Context, op: Operation) { let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); let selection = doc.selection(view.id); if cx.register != Some('_') { // first yank the selection + let text = doc.text().slice(..); let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); let reg_name = cx.register.unwrap_or('"'); - let registers = &mut cx.editor.registers; - let reg = registers.get_mut(reg_name); - reg.write(values); + cx.editor.registers.write(reg_name, values); }; // then delete @@ -2378,7 +2401,7 @@ fn buffer_picker(cx: &mut Context) { .selection(view_id) .primary() .cursor_line(doc.text().slice(..)); - Some((meta.path.clone()?, Some((line, line)))) + Some((meta.id.into(), Some((line, line)))) }, ); cx.push_layer(Box::new(overlayed(picker))); @@ -2445,7 +2468,6 @@ fn jumplist_picker(cx: &mut Context) { .views() .flat_map(|(view, _)| { view.jumps - .get() .iter() .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) }) @@ -2459,7 +2481,7 @@ fn jumplist_picker(cx: &mut Context) { |editor, meta| { let doc = &editor.documents.get(&meta.id)?; let line = meta.selection.primary().cursor_line(doc.text().slice(..)); - Some((meta.path.clone()?, Some((line, line)))) + Some((meta.path.clone()?.into(), Some((line, line)))) }, ); cx.push_layer(Box::new(overlayed(picker))); @@ -2472,13 +2494,11 @@ impl ui::menu::Item for MappableCommand { let fmt_binding = |bindings: &Vec>| -> String { bindings.iter().fold(String::new(), |mut acc, bind| { if !acc.is_empty() { - acc.push_str(", "); + acc.push(' '); + } + for key in bind { + acc.push_str(&key.key_sequence_format()); } - bind.iter().fold(false, |needs_plus, key| { - write!(&mut acc, "{}{}", if needs_plus { "+" } else { "" }, key) - .expect("Writing to a string can only fail on an Out-Of-Memory error"); - true - }); acc }) }; @@ -2762,15 +2782,15 @@ fn goto_line(cx: &mut Context) { fn goto_line_impl(editor: &mut Editor, count: Option) { if let Some(count) = count { let (view, doc) = current!(editor); - let max_line = if doc.text().line(doc.text().len_lines() - 1).len_chars() == 0 { + let text = doc.text().slice(..); + let max_line = if text.line(text.len_lines() - 1).len_chars() == 0 { // If the last line is blank, don't jump to it. - doc.text().len_lines().saturating_sub(2) + text.len_lines().saturating_sub(2) } else { - doc.text().len_lines() - 1 + text.len_lines() - 1 }; let line_idx = std::cmp::min(count.get() - 1, max_line); - let text = doc.text().slice(..); - let pos = doc.text().line_to_char(line_idx); + let pos = text.line_to_char(line_idx); let selection = doc .selection(view.id) .clone() @@ -2783,14 +2803,14 @@ fn goto_line_impl(editor: &mut Editor, count: Option) { fn goto_last_line(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let line_idx = if doc.text().line(doc.text().len_lines() - 1).len_chars() == 0 { + let text = doc.text().slice(..); + let line_idx = if text.line(text.len_lines() - 1).len_chars() == 0 { // If the last line is blank, don't jump to it. - doc.text().len_lines().saturating_sub(2) + text.len_lines().saturating_sub(2) } else { - doc.text().len_lines() - 1 + text.len_lines() - 1 }; - let text = doc.text().slice(..); - let pos = doc.text().line_to_char(line_idx); + let pos = text.line_to_char(line_idx); let selection = doc .selection(view.id) .clone() @@ -2964,22 +2984,22 @@ pub mod insert { use helix_core::chars::char_is_word; let mut iter = text.chars_at(cursor); iter.reverse(); - for _ in 0..config.completion_trigger_len { match iter.next() { Some(c) if char_is_word(c) => {} - Some(c) if config.completion_trigger_chars.contains(&c) => {} _ => return, } } super::completion(cx); } - pub fn is_server_trigger_char(doc: &Document, ch: char) -> bool { + fn language_server_completion(cx: &mut Context, ch: char) { use helix_lsp::lsp; + // if ch matches completion char, trigger completion + let doc = doc_mut!(cx.editor); let language_server = match doc.language_server() { Some(language_server) => language_server, - None => return false, + None => return, }; let capabilities = language_server.capabilities(); @@ -2989,35 +3009,11 @@ pub mod insert { .. }) = &capabilities.completion_provider { - triggers.iter().any(|trigger| trigger.contains(ch)) - } else { - false - } - } - - fn language_server_completion(cx: &mut Context, ch: char) { - use helix_core::chars::char_is_word; - - let config = cx.editor.config(); - if !config.auto_completion { - return; - } - let (view, doc) = current_ref!(cx.editor); - if char_is_word(ch) && doc.savepoint.is_none() { - let text = doc.text().slice(..); - let cursor = doc.selection(view.id).primary().cursor(text); - let mut iter = text.chars_at(cursor); - iter.reverse(); - for _ in 0..config.completion_trigger_len { - if iter.next().map_or(true, |c| !char_is_word(c)) { - return; - } + // TODO: what if trigger is multiple chars long + if triggers.iter().any(|trigger| trigger.contains(ch)) { + cx.editor.clear_idle_timer(); + super::completion(cx); } - cx.editor.reset_idle_timer(); - return; - } - if is_server_trigger_char(doc, ch) { - cx.editor.reset_idle_timer_zero(); } } @@ -3130,40 +3126,59 @@ pub mod insert { let curr = contents.get_char(pos).unwrap_or(' '); let current_line = text.char_to_line(pos); - let indent = indent::indent_for_newline( - doc.language_config(), - doc.syntax(), - &doc.indent_style, - doc.tab_width(), - text, - current_line, - pos, - current_line, - ); - let mut text = String::new(); - // If we are between pairs (such as brackets), we want to - // insert an additional line which is indented one level - // more and place the cursor there - let on_auto_pair = doc - .auto_pairs(cx.editor) - .and_then(|pairs| pairs.get(prev)) - .and_then(|pair| if pair.close == curr { Some(pair) } else { None }) - .is_some(); - - let local_offs = if on_auto_pair { - let inner_indent = indent.clone() + doc.indent_style.as_str(); - text.reserve_exact(2 + indent.len() + inner_indent.len()); - text.push_str(doc.line_ending.as_str()); - text.push_str(&inner_indent); - let local_offs = text.chars().count(); - text.push_str(doc.line_ending.as_str()); - text.push_str(&indent); - local_offs + let line_is_only_whitespace = text + .line(current_line) + .chars() + .all(|char| char.is_ascii_whitespace()); + + let mut new_text = String::new(); + + // If the current line is all whitespace, insert a line ending at the beginning of + // the current line. This makes the current line empty and the new line contain the + // indentation of the old line. + let (from, to, local_offs) = if line_is_only_whitespace { + let line_start = text.line_to_char(current_line); + new_text.push_str(doc.line_ending.as_str()); + + (line_start, line_start, new_text.chars().count()) } else { - text.reserve_exact(1 + indent.len()); - text.push_str(doc.line_ending.as_str()); - text.push_str(&indent); - text.chars().count() + let indent = indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.indent_style, + doc.tab_width(), + text, + current_line, + pos, + current_line, + ); + + // If we are between pairs (such as brackets), we want to + // insert an additional line which is indented one level + // more and place the cursor there + let on_auto_pair = doc + .auto_pairs(cx.editor) + .and_then(|pairs| pairs.get(prev)) + .and_then(|pair| if pair.close == curr { Some(pair) } else { None }) + .is_some(); + + let local_offs = if on_auto_pair { + let inner_indent = indent.clone() + doc.indent_style.as_str(); + new_text.reserve_exact(2 + indent.len() + inner_indent.len()); + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&inner_indent); + let local_offs = new_text.chars().count(); + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&indent); + local_offs + } else { + new_text.reserve_exact(1 + indent.len()); + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&indent); + new_text.chars().count() + }; + + (pos, pos, local_offs) }; let new_range = if doc.restore_cursor { @@ -3184,9 +3199,9 @@ pub mod insert { // range.replace(|range| range.is_empty(), head); -> fn extend if cond true, new head pos // can be used with cx.mode to do replace or extend on most changes ranges.push(new_range); - global_offs += text.chars().count(); + global_offs += new_text.chars().count(); - (pos, pos, Some(text.into())) + (from, to, Some(new_text.into())) }); transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); @@ -3347,7 +3362,7 @@ fn undo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.undo(view) { + if !doc.undo(view.id) { cx.editor.set_status("Already at oldest change"); break; } @@ -3358,7 +3373,7 @@ fn redo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.redo(view) { + if !doc.redo(view.id) { cx.editor.set_status("Already at newest change"); break; } @@ -3370,7 +3385,7 @@ fn earlier(cx: &mut Context) { let (view, doc) = current!(cx.editor); for _ in 0..count { // rather than doing in batch we do this so get error halfway - if !doc.earlier(view, UndoKind::Steps(1)) { + if !doc.earlier(view.id, UndoKind::Steps(1)) { cx.editor.set_status("Already at oldest change"); break; } @@ -3382,7 +3397,7 @@ fn later(cx: &mut Context) { let (view, doc) = current!(cx.editor); for _ in 0..count { // rather than doing in batch we do this so get error halfway - if !doc.later(view, UndoKind::Steps(1)) { + if !doc.later(view.id, UndoKind::Steps(1)) { cx.editor.set_status("Already at newest change"); break; } @@ -3511,7 +3526,14 @@ enum Paste { Cursor, } -fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Paste, count: usize) { +fn paste_impl( + values: &[String], + doc: &mut Document, + view: &mut View, + action: Paste, + count: usize, + mode: Mode, +) { if values.is_empty() { return; } @@ -3530,7 +3552,6 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Pa .any(|value| get_line_ending_of_str(value).is_some()); // Only compiled once. - #[allow(clippy::trivial_regex)] static REGEX: Lazy = Lazy::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); let mut values = values .iter() @@ -3541,9 +3562,10 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Pa let text = doc.text(); let selection = doc.selection(view.id); + let mut offset = 0; let mut ranges = SmallVec::with_capacity(selection.len()); - let transaction = Transaction::change_by_selection(text, selection, |range| { + let mut transaction = Transaction::change_by_selection(text, selection, |range| { let pos = match (action, linewise) { // paste linewise before (Paste::Before, true) => text.line_to_char(text.char_to_line(range.from())), @@ -3566,13 +3588,18 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Pa .as_ref() .map(|content| content.chars().count()) .unwrap_or_default(); + let anchor = offset + pos; - ranges.push(Range::new(pos, pos + value_len)); + let new_range = Range::new(anchor, anchor + value_len).with_direction(range.direction()); + ranges.push(new_range); + offset += value_len; (pos, pos, value) }); - let transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + if mode == Mode::Normal { + transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + } apply_transaction(&transaction, doc, view); } @@ -3584,7 +3611,7 @@ pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { Mode::Normal => Paste::Before, }; let (view, doc) = current!(cx.editor); - paste_impl(&[contents], doc, view, paste, count); + paste_impl(&[contents], doc, view, paste, count, cx.editor.mode); } fn paste_clipboard_impl( @@ -3596,7 +3623,7 @@ fn paste_clipboard_impl( let (view, doc) = current!(editor); match editor.clipboard_provider.get_contents(clipboard_type) { Ok(contents) => { - paste_impl(&[contents], doc, view, action, count); + paste_impl(&[contents], doc, view, action, count, editor.mode); Ok(()) } Err(e) => Err(e.context("Couldn't get system clipboard contents")), @@ -3715,7 +3742,7 @@ fn paste(cx: &mut Context, pos: Paste) { let registers = &mut cx.editor.registers; if let Some(values) = registers.read(reg_name) { - paste_impl(values, doc, view, pos, count); + paste_impl(values, doc, view, pos, count, cx.editor.mode); } } @@ -3810,7 +3837,7 @@ fn format_selections(cx: &mut Context) { let (view, doc) = current!(cx.editor); // via lsp if available - // else via tree-sitter indentation calculations + // TODO: else via tree-sitter indentation calculations let language_server = match doc.language_server() { Some(language_server) => language_server, @@ -3823,36 +3850,43 @@ fn format_selections(cx: &mut Context) { .map(|range| range_to_lsp_range(doc.text(), *range, language_server.offset_encoding())) .collect(); - // TODO: all of the TODO's and commented code inside the loop, - // to make this actually work. - for _range in ranges { - let _language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - // TODO: handle fails - // TODO: concurrent map + if ranges.len() != 1 { + cx.editor + .set_error("format_selections only supports a single selection for now"); + return; + } - // TODO: need to block to get the formatting + // TODO: handle fails + // TODO: concurrent map over all ranges - // let edits = block_on(language_server.text_document_range_formatting( - // doc.identifier(), - // range, - // lsp::FormattingOptions::default(), - // )) - // .unwrap_or_default(); + let range = ranges[0]; - // let transaction = helix_lsp::util::generate_transaction_from_edits( - // doc.text(), - // edits, - // language_server.offset_encoding(), - // ); + let request = match language_server.text_document_range_formatting( + doc.identifier(), + range, + lsp::FormattingOptions::default(), + None, + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support range formatting"); + return; + } + }; - // apply_transaction(&transaction, doc, view); - } + let edits = tokio::task::block_in_place(|| helix_lsp::block_on(request)).unwrap_or_default(); + + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + edits, + language_server.offset_encoding(), + ); + + apply_transaction(&transaction, doc, view); } -fn join_selections_inner(cx: &mut Context, select_space: bool) { +fn join_selections_impl(cx: &mut Context, select_space: bool) { use movement::skip_while; let (view, doc) = current!(cx.editor); let text = doc.text(); @@ -3931,11 +3965,11 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { } fn join_selections(cx: &mut Context) { - join_selections_inner(cx, false) + join_selections_impl(cx, false) } fn join_selections_space(cx: &mut Context) { - join_selections_inner(cx, true) + join_selections_impl(cx, true) } fn keep_selections(cx: &mut Context) { @@ -3985,13 +4019,9 @@ pub fn completion(cx: &mut Context) { let pos = pos_to_lsp_pos(doc.text(), cursor, offset_encoding); - let future = language_server.completion(doc.identifier(), pos, None); - let future = async move { - match future.await { - Ok(v) => Ok(v), - Err(helix_lsp::Error::Timeout) => Ok(serde_json::Value::Null), - Err(e) => Err(e), - } + let future = match language_server.completion(doc.identifier(), pos, None) { + Some(future) => future, + None => return, }; let trigger_offset = cursor; @@ -4004,56 +4034,29 @@ pub fn completion(cx: &mut Context) { iter.reverse(); let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count(); let start_offset = cursor.saturating_sub(offset); - let prefix = text.slice(start_offset..cursor).to_string(); - doc.savepoint(); - let trigger_version = doc.version(); cx.callback( future, move |editor, compositor, response: Option| { - let doc = doc_mut!(editor); - let savepoint = match doc.savepoint.take() { - Some(s) => s, - None => return, - }; if editor.mode != Mode::Insert { - return; - } - if savepoint.0 != trigger_version { - doc.savepoint = Some(savepoint); + // we're not in insert mode anymore return; } - let mut items = match response { + let items = match response { Some(lsp::CompletionResponse::Array(items)) => items, // TODO: do something with is_incomplete Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: _is_incomplete, items, })) => items, - None => { - editor.set_status( - "The completion response is none and will request server again", - ); - editor.reset_idle_timer(); - return; - } + None => Vec::new(), }; - if !prefix.is_empty() { - items.retain(|item| { - item.filter_text - .as_ref() - .unwrap_or(&item.label) - .starts_with(&prefix) - }); - } - if items.is_empty() { - // editor.set_error("No completion available".to_string()); + // editor.set_error("No completion available"); return; } - doc.savepoint = Some(savepoint); let size = compositor.size(); let ui = compositor.find::().unwrap(); ui.set_completion( @@ -4428,7 +4431,7 @@ fn align_view_middle(cx: &mut Context) { view.offset.col = pos .col - .saturating_sub((view.inner_area().width as usize) / 2); + .saturating_sub((view.inner_area(doc).width as usize) / 2); } fn scroll_up(cx: &mut Context) { @@ -4611,8 +4614,13 @@ fn surround_add(cx: &mut Context) { let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); let (open, close) = surround::get_pair(ch); + // The number of chars in get_pair + let surround_len = 2; let mut changes = Vec::with_capacity(selection.len() * 2); + let mut ranges = SmallVec::with_capacity(selection.len()); + let mut offs = 0; + for range in selection.iter() { let mut o = Tendril::new(); o.push(open); @@ -4620,10 +4628,21 @@ fn surround_add(cx: &mut Context) { c.push(close); changes.push((range.from(), range.from(), Some(o))); changes.push((range.to(), range.to(), Some(c))); + + // Add 2 characters to the range to select them + ranges.push( + Range::new(offs + range.from(), offs + range.to() + surround_len) + .with_direction(range.direction()), + ); + + // Add 2 characters to the offset for the next ranges + offs += surround_len; } - let transaction = Transaction::change(doc.text(), changes.into_iter()); + let transaction = Transaction::change(doc.text(), changes.into_iter()) + .with_selection(Selection::new(ranges, selection.primary_index())); apply_transaction(&transaction, doc, view); + exit_select_mode(cx); }) } @@ -4663,6 +4682,7 @@ fn surround_replace(cx: &mut Context) { }), ); apply_transaction(&transaction, doc, view); + exit_select_mode(cx); }); }) } @@ -4690,6 +4710,7 @@ fn surround_delete(cx: &mut Context) { let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); apply_transaction(&transaction, doc, view); + exit_select_mode(cx); }) } @@ -4854,13 +4875,24 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { let mut ranges = SmallVec::with_capacity(selection.len()); let text = doc.text().slice(..); + let mut shell_output: Option = None; + let mut offset = 0isize; for range in selection.ranges() { - let fragment = range.slice(text); - let (output, success) = match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { - Ok(result) => result, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; + let (output, success) = if let Some(output) = shell_output.as_ref() { + (output.clone(), true) + } else { + let fragment = range.slice(text); + match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { + Ok(result) => { + if !pipe { + shell_output = Some(result.0.clone()); + } + result + } + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } } }; @@ -4869,13 +4901,23 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { return; } - let (from, to) = match behavior { - ShellBehavior::Replace => (range.from(), range.to()), - ShellBehavior::Insert => (range.from(), range.from()), - ShellBehavior::Append => (range.to(), range.to()), - _ => (range.from(), range.from()), + let output_len = output.chars().count(); + + let (from, to, deleted_len) = match behavior { + ShellBehavior::Replace => (range.from(), range.to(), range.len()), + ShellBehavior::Insert => (range.from(), range.from(), 0), + ShellBehavior::Append => (range.to(), range.to(), 0), + _ => (range.from(), range.from(), 0), }; - ranges.push(Range::new(to, to + output.chars().count())); + + // These `usize`s cannot underflow because selection ranges cannot overlap. + // Once the MSRV is 1.66.0 (mixed_integer_ops is stabilized), we can use checked + // arithmetic to assert this. + let anchor = (to as isize + offset - deleted_len as isize) as usize; + let new_range = Range::new(anchor, anchor + output_len).with_direction(range.direction()); + ranges.push(new_range); + offset = offset + output_len as isize - deleted_len as isize; + changes.push((from, to, Some(output))); } @@ -4948,14 +4990,18 @@ fn add_newline_impl(cx: &mut Context, open: Open) { apply_transaction(&transaction, doc, view); } +enum IncrementDirection { + Increase, + Decrease, +} /// Increment object under cursor by count. fn increment(cx: &mut Context) { - increment_impl(cx, cx.count() as i64); + increment_impl(cx, IncrementDirection::Increase); } /// Decrement object under cursor by count. fn decrement(cx: &mut Context) { - increment_impl(cx, -(cx.count() as i64)); + increment_impl(cx, IncrementDirection::Decrease); } /// This function differs from find_next_char_impl in that it stops searching at the newline, but also @@ -4979,7 +5025,7 @@ fn find_next_char_until_newline( } /// Decrement object under cursor by `amount`. -fn increment_impl(cx: &mut Context, amount: i64) { +fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { // TODO: when incrementing or decrementing a number that gets a new digit or lose one, the // selection is updated improperly. find_char_impl( @@ -4991,6 +5037,17 @@ fn increment_impl(cx: &mut Context, amount: i64) { 1, ); + // Increase by 1 if `IncrementDirection` is `Increase` + // Decrease by 1 if `IncrementDirection` is `Decrease` + let sign = match increment_direction { + IncrementDirection::Increase => 1, + IncrementDirection::Decrease => -1, + }; + let mut amount = sign * cx.count() as i64; + + // If the register is `#` then increase or decrease the `amount` by 1 per element + let increase_by = if cx.register == Some('#') { sign } else { 0 }; + let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); let text = doc.text().slice(..); @@ -5010,6 +5067,8 @@ fn increment_impl(cx: &mut Context, amount: i64) { let (range, new_text) = incrementor.increment(amount); + amount += increase_by; + Some((range.from(), range.to(), Some(new_text))) }) .collect(); @@ -5026,16 +5085,20 @@ fn increment_impl(cx: &mut Context, amount: i64) { overlapping_indexes.insert(i + 1); } } - let changes = changes.into_iter().enumerate().filter_map(|(i, change)| { - if overlapping_indexes.contains(&i) { - None - } else { - Some(change) - } - }); + let changes: Vec<_> = changes + .into_iter() + .enumerate() + .filter_map(|(i, change)| { + if overlapping_indexes.contains(&i) { + None + } else { + Some(change) + } + }) + .collect(); - if changes.clone().count() > 0 { - let transaction = Transaction::change(doc.text(), changes); + if !changes.is_empty() { + let transaction = Transaction::change(doc.text(), changes.into_iter()); let transaction = transaction.with_selection(selection.clone()); apply_transaction(&transaction, doc, view); @@ -5057,7 +5120,7 @@ fn record_macro(cx: &mut Context) { } }) .collect::(); - cx.editor.registers.get_mut(reg).write(vec![s]); + cx.editor.registers.write(reg, vec![s]); cx.editor .set_status(format!("Recorded to register [{}]", reg)); } else { diff --git a/helix-term/src/commands.rs.orig b/helix-term/src/commands.rs.orig new file mode 100644 index 00000000..75fd9f81 --- /dev/null +++ b/helix-term/src/commands.rs.orig @@ -0,0 +1,5228 @@ +pub(crate) mod dap; +pub(crate) mod lsp; +pub(crate) mod typed; + +pub use dap::*; +pub use lsp::*; +use tui::text::Spans; +pub use typed::*; + +use helix_core::{ + comment, coords_at_pos, encoding, find_first_non_whitespace_char, find_root, graphemes, + history::UndoKind, + increment::date_time::DateTimeIncrementor, + increment::{number::NumberIncrementor, Increment}, + indent, + indent::IndentStyle, + line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, + match_brackets, + movement::{self, Direction}, + object, pos_at_coords, pos_at_visual_coords, + regex::{self, Regex, RegexBuilder}, + search::{self, CharMatcher}, + selection, shellwords, surround, textobject, + tree_sitter::Node, + unicode::width::UnicodeWidthChar, + visual_coords_at_pos, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, + SmallVec, Tendril, Transaction, +}; +use helix_view::{ + apply_transaction, + clipboard::ClipboardType, + document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, + editor::{Action, Motion}, + info::Info, + input::KeyEvent, + keyboard::KeyCode, + tree, + view::View, + Document, DocumentId, Editor, ViewId, +}; + +use anyhow::{anyhow, bail, ensure, Context as _}; +use fuzzy_matcher::FuzzyMatcher; +use insert::*; +use movement::Movement; + +use crate::{ + args, + compositor::{self, Component, Compositor}, + job::Callback, + keymap::ReverseKeymap, + ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, +}; + +use crate::job::{self, Jobs}; +use futures_util::StreamExt; +use std::{collections::HashMap, fmt, future::Future}; +use std::{collections::HashSet, num::NonZeroUsize}; + +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use once_cell::sync::Lazy; +use serde::de::{self, Deserialize, Deserializer}; + +use grep_regex::RegexMatcherBuilder; +use grep_searcher::{sinks, BinaryDetection, SearcherBuilder}; +use ignore::{DirEntry, WalkBuilder, WalkState}; +use tokio_stream::wrappers::UnboundedReceiverStream; + +pub struct Context<'a> { + pub register: Option, + pub count: Option, + pub editor: &'a mut Editor, + + pub callback: Option, + pub on_next_key_callback: Option>, + pub jobs: &'a mut Jobs, +} + +impl<'a> Context<'a> { + /// Push a new component onto the compositor. + pub fn push_layer(&mut self, component: Box) { + self.callback = Some(Box::new(|compositor: &mut Compositor, _| { + compositor.push(component) + })); + } + + #[inline] + pub fn on_next_key( + &mut self, + on_next_key_callback: impl FnOnce(&mut Context, KeyEvent) + 'static, + ) { + self.on_next_key_callback = Some(Box::new(on_next_key_callback)); + } + + #[inline] + pub fn callback( + &mut self, + call: impl Future> + 'static + Send, + callback: F, + ) where + T: for<'de> serde::Deserialize<'de> + Send + 'static, + F: FnOnce(&mut Editor, &mut Compositor, T) + Send + 'static, + { + let callback = Box::pin(async move { + let json = call.await?; + let response = serde_json::from_value(json)?; + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + callback(editor, compositor, response) + }, + )); + Ok(call) + }); + self.jobs.callback(callback); + } + + /// Returns 1 if no explicit count was provided + #[inline] + pub fn count(&self) -> usize { + self.count.map_or(1, |v| v.get()) + } +} + +use helix_view::{align_view, Align}; + +/// A MappableCommand is either a static command like "jump_view_up" or a Typable command like +/// :format. It causes a side-effect on the state (usually by creating and applying a transaction). +/// Both of these types of commands can be mapped with keybindings in the config.toml. +#[derive(Clone)] +pub enum MappableCommand { + Typable { + name: String, + args: Vec, + doc: String, + }, + Static { + name: &'static str, + fun: fn(cx: &mut Context), + doc: &'static str, + }, +} + +macro_rules! static_commands { + ( $($name:ident, $doc:literal,)* ) => { + $( + #[allow(non_upper_case_globals)] + pub const $name: Self = Self::Static { + name: stringify!($name), + fun: $name, + doc: $doc + }; + )* + + pub const STATIC_COMMAND_LIST: &'static [Self] = &[ + $( Self::$name, )* + ]; + } +} + +impl MappableCommand { + pub fn execute(&self, cx: &mut Context) { + match &self { + Self::Typable { name, args, doc: _ } => { + let args: Vec> = args.iter().map(Cow::from).collect(); + if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { + let mut cx = compositor::Context { + editor: cx.editor, + jobs: cx.jobs, + scroll: None, + }; + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { + cx.editor.set_error(format!("{}", e)); + } + } + } + Self::Static { fun, .. } => (fun)(cx), + } + } + + pub fn name(&self) -> &str { + match &self { + Self::Typable { name, .. } => name, + Self::Static { name, .. } => name, + } + } + + pub fn doc(&self) -> &str { + match &self { + Self::Typable { doc, .. } => doc, + Self::Static { doc, .. } => doc, + } + } + + #[rustfmt::skip] + static_commands!( + no_op, "Do nothing", + move_char_left, "Move left", + move_char_right, "Move right", + move_line_up, "Move up", + move_line_down, "Move down", + extend_char_left, "Extend left", + extend_char_right, "Extend right", + extend_line_up, "Extend up", + extend_line_down, "Extend down", + copy_selection_on_next_line, "Copy selection on next line", + copy_selection_on_prev_line, "Copy selection on previous line", + move_next_word_start, "Move to start of next word", + move_prev_word_start, "Move to start of previous word", + move_next_word_end, "Move to end of next word", + move_prev_word_end, "Move to end of previous word", + move_next_long_word_start, "Move to start of next long word", + move_prev_long_word_start, "Move to start of previous long word", + move_next_long_word_end, "Move to end of next long word", + extend_next_word_start, "Extend to start of next word", + extend_prev_word_start, "Extend to start of previous word", + extend_next_word_end, "Extend to end of next word", + extend_prev_word_end, "Extend to end of previous word", + extend_next_long_word_start, "Extend to start of next long word", + extend_prev_long_word_start, "Extend to start of previous long word", + extend_next_long_word_end, "Extend to end of next long word", + find_till_char, "Move till next occurrence of char", + find_next_char, "Move to next occurrence of char", + extend_till_char, "Extend till next occurrence of char", + extend_next_char, "Extend to next occurrence of char", + till_prev_char, "Move till previous occurrence of char", + find_prev_char, "Move to previous occurrence of char", + extend_till_prev_char, "Extend till previous occurrence of char", + extend_prev_char, "Extend to previous occurrence of char", + repeat_last_motion, "Repeat last motion", + replace, "Replace with new char", + switch_case, "Switch (toggle) case", + switch_to_uppercase, "Switch to uppercase", + switch_to_lowercase, "Switch to lowercase", + page_up, "Move page up", + page_down, "Move page down", + half_page_up, "Move half page up", + half_page_down, "Move half page down", + select_all, "Select whole document", + select_regex, "Select all regex matches inside selections", + split_selection, "Split selections on regex matches", + split_selection_on_newline, "Split selection on newlines", + search, "Search for regex pattern", + rsearch, "Reverse search for regex pattern", + search_next, "Select next search match", + search_prev, "Select previous search match", + extend_search_next, "Add next search match to selection", + extend_search_prev, "Add previous search match to selection", + search_selection, "Use current selection as search pattern", + make_search_word_bounded, "Modify current search to make it word bounded", + global_search, "Global search in workspace folder", + extend_line, "Select current line, if already selected, extend to another line based on the anchor", + extend_line_below, "Select current line, if already selected, extend to next line", + extend_line_above, "Select current line, if already selected, extend to previous line", + extend_to_line_bounds, "Extend selection to line bounds", + shrink_to_line_bounds, "Shrink selection to line bounds", + delete_selection, "Delete selection", + delete_selection_noyank, "Delete selection without yanking", + change_selection, "Change selection", + change_selection_noyank, "Change selection without yanking", + collapse_selection, "Collapse selection into single cursor", + flip_selections, "Flip selection cursor and anchor", + ensure_selections_forward, "Ensure all selections face forward", + insert_mode, "Insert before selection", + append_mode, "Append after selection", + command_mode, "Enter command mode", + file_picker, "Open file picker", + file_picker_in_current_directory, "Open file picker at current working directory", + code_action, "Perform code action", + workspace_command_picker, "Open workspace command picker", + buffer_picker, "Open buffer picker", + jumplist_picker, "Open jumplist picker", + symbol_picker, "Open symbol picker", + select_references_to_symbol_under_cursor, "Select symbol references", + workspace_symbol_picker, "Open workspace symbol picker", + diagnostics_picker, "Open diagnostic picker", + workspace_diagnostics_picker, "Open workspace diagnostic picker", + last_picker, "Open last picker", + insert_at_line_start, "Insert at start of line", + insert_at_line_end, "Insert at end of line", + open_below, "Open new line below selection", + open_above, "Open new line above selection", + normal_mode, "Enter normal mode", + select_mode, "Enter selection extend mode", + exit_select_mode, "Exit selection mode", + goto_definition, "Goto definition", + add_newline_above, "Add newline above", + add_newline_below, "Add newline below", + goto_type_definition, "Goto type definition", + goto_implementation, "Goto implementation", + goto_file_start, "Goto line number else file start", + goto_file_end, "Goto file end", + goto_file, "Goto files in selection", + goto_file_hsplit, "Goto files in selection (hsplit)", + goto_file_vsplit, "Goto files in selection (vsplit)", + goto_reference, "Goto references", + goto_window_top, "Goto window top", + goto_window_center, "Goto window center", + goto_window_bottom, "Goto window bottom", + goto_last_accessed_file, "Goto last accessed file", + goto_last_modified_file, "Goto last modified file", + goto_last_modification, "Goto last modification", + goto_line, "Goto line", + goto_last_line, "Goto last line", + goto_first_diag, "Goto first diagnostic", + goto_last_diag, "Goto last diagnostic", + goto_next_diag, "Goto next diagnostic", + goto_prev_diag, "Goto previous diagnostic", + goto_line_start, "Goto line start", + goto_line_end, "Goto line end", + goto_next_buffer, "Goto next buffer", + goto_previous_buffer, "Goto previous buffer", + goto_line_end_newline, "Goto newline at line end", + goto_first_nonwhitespace, "Goto first non-blank in line", + trim_selections, "Trim whitespace from selections", + extend_to_line_start, "Extend to line start", + extend_to_line_end, "Extend to line end", + extend_to_line_end_newline, "Extend to line end", + signature_help, "Show signature help", + insert_tab, "Insert tab char", + insert_newline, "Insert newline char", + delete_char_backward, "Delete previous char", + delete_char_forward, "Delete next char", + delete_word_backward, "Delete previous word", + delete_word_forward, "Delete next word", + kill_to_line_start, "Delete till start of line", + kill_to_line_end, "Delete till end of line", + undo, "Undo change", + redo, "Redo change", + earlier, "Move backward in history", + later, "Move forward in history", + commit_undo_checkpoint, "Commit changes to new checkpoint", + yank, "Yank selection", + yank_joined_to_clipboard, "Join and yank selections to clipboard", + yank_main_selection_to_clipboard, "Yank main selection to clipboard", + yank_joined_to_primary_clipboard, "Join and yank selections to primary clipboard", + yank_main_selection_to_primary_clipboard, "Yank main selection to primary clipboard", + replace_with_yanked, "Replace with yanked text", + replace_selections_with_clipboard, "Replace selections by clipboard content", + replace_selections_with_primary_clipboard, "Replace selections by primary clipboard", + paste_after, "Paste after selection", + paste_before, "Paste before selection", + paste_clipboard_after, "Paste clipboard after selections", + paste_clipboard_before, "Paste clipboard before selections", + paste_primary_clipboard_after, "Paste primary clipboard after selections", + paste_primary_clipboard_before, "Paste primary clipboard before selections", + indent, "Indent selection", + unindent, "Unindent selection", + format_selections, "Format selection", + join_selections, "Join lines inside selection", + join_selections_space, "Join lines inside selection and select spaces", + keep_selections, "Keep selections matching regex", + remove_selections, "Remove selections matching regex", + align_selections, "Align selections in column", + keep_primary_selection, "Keep primary selection", + remove_primary_selection, "Remove primary selection", + completion, "Invoke completion popup", + hover, "Show docs for item under cursor", + toggle_comments, "Comment/uncomment selections", + rotate_selections_forward, "Rotate selections forward", + rotate_selections_backward, "Rotate selections backward", + rotate_selection_contents_forward, "Rotate selection contents forward", + rotate_selection_contents_backward, "Rotate selections contents backward", + expand_selection, "Expand selection to parent syntax node", + shrink_selection, "Shrink selection to previously expanded syntax node", + select_next_sibling, "Select next sibling in syntax tree", + select_prev_sibling, "Select previous sibling in syntax tree", + jump_forward, "Jump forward on jumplist", + jump_backward, "Jump backward on jumplist", + save_selection, "Save current selection to jumplist", + jump_view_right, "Jump to right split", + jump_view_left, "Jump to left split", + jump_view_up, "Jump to split above", + jump_view_down, "Jump to split below", + swap_view_right, "Swap with right split", + swap_view_left, "Swap with left split", + swap_view_up, "Swap with split above", + swap_view_down, "Swap with split below", + transpose_view, "Transpose splits", + rotate_view, "Goto next window", + hsplit, "Horizontal bottom split", + hsplit_new, "Horizontal bottom split scratch buffer", + vsplit, "Vertical right split", + vsplit_new, "Vertical right split scratch buffer", + wclose, "Close window", + wonly, "Close windows except current", + select_register, "Select register", + insert_register, "Insert register", + align_view_middle, "Align view middle", + align_view_top, "Align view top", + align_view_center, "Align view center", + align_view_bottom, "Align view bottom", + scroll_up, "Scroll view up", + scroll_down, "Scroll view down", + match_brackets, "Goto matching bracket", + surround_add, "Surround add", + surround_replace, "Surround replace", + surround_delete, "Surround delete", + select_textobject_around, "Select around object", + select_textobject_inner, "Select inside object", + goto_next_function, "Goto next function", + goto_prev_function, "Goto previous function", + goto_next_class, "Goto next class", + goto_prev_class, "Goto previous class", + goto_next_parameter, "Goto next parameter", + goto_prev_parameter, "Goto previous parameter", + goto_next_comment, "Goto next comment", + goto_prev_comment, "Goto previous comment", + goto_next_test, "Goto next test", + goto_prev_test, "Goto previous test", + goto_next_paragraph, "Goto next paragraph", + goto_prev_paragraph, "Goto previous paragraph", + dap_launch, "Launch debug target", + dap_toggle_breakpoint, "Toggle breakpoint", + dap_continue, "Continue program execution", + dap_pause, "Pause program execution", + dap_step_in, "Step in", + dap_step_out, "Step out", + dap_next, "Step to next", + dap_variables, "List variables", + dap_terminate, "End debug session", + dap_edit_condition, "Edit breakpoint condition on current line", + dap_edit_log, "Edit breakpoint log message on current line", + dap_switch_thread, "Switch current thread", + dap_switch_stack_frame, "Switch stack frame", + dap_enable_exceptions, "Enable exception breakpoints", + dap_disable_exceptions, "Disable exception breakpoints", + shell_pipe, "Pipe selections through shell command", + shell_pipe_to, "Pipe selections into shell command ignoring output", + shell_insert_output, "Insert shell command output before selections", + shell_append_output, "Append shell command output after selections", + shell_keep_pipe, "Filter selections with shell predicate", + suspend, "Suspend and return to shell", + rename_symbol, "Rename symbol", + increment, "Increment item under cursor", + decrement, "Decrement item under cursor", + record_macro, "Record macro", + replay_macro, "Replay macro", + command_palette, "Open command pallete", + toggle_or_focus_explorer, "toggle or focus explorer", + open_explorer_recursion, "open explorer recursion", + close_explorer, "close explorer", + ); +} + +impl fmt::Debug for MappableCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("MappableCommand") + .field(&self.name()) + .finish() + } +} + +impl fmt::Display for MappableCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +impl std::str::FromStr for MappableCommand { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if let Some(suffix) = s.strip_prefix(':') { + let mut typable_command = suffix.split(' ').into_iter().map(|arg| arg.trim()); + let name = typable_command + .next() + .ok_or_else(|| anyhow!("Expected typable command name"))?; + let args = typable_command + .map(|s| s.to_owned()) + .collect::>(); + typed::TYPABLE_COMMAND_MAP + .get(name) + .map(|cmd| MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: format!(":{} {:?}", cmd.name, args), + args, + }) + .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) + } else { + MappableCommand::STATIC_COMMAND_LIST + .iter() + .find(|cmd| cmd.name() == s) + .cloned() + .ok_or_else(|| anyhow!("No command named '{}'", s)) + } + } +} + +impl<'de> Deserialize<'de> for MappableCommand { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + s.parse().map_err(de::Error::custom) + } +} + +impl PartialEq for MappableCommand { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + MappableCommand::Typable { + name: first_name, .. + }, + MappableCommand::Typable { + name: second_name, .. + }, + ) => first_name == second_name, + ( + MappableCommand::Static { + name: first_name, .. + }, + MappableCommand::Static { + name: second_name, .. + }, + ) => first_name == second_name, + _ => false, + } + } +} + +fn no_op(_cx: &mut Context) {} + +fn move_impl(cx: &mut Context, move_fn: F, dir: Direction, behaviour: Movement) +where + F: Fn(RopeSlice, Range, Direction, usize, Movement, usize) -> Range, +{ + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| move_fn(text, range, dir, count, behaviour, doc.tab_width())); + doc.set_selection(view.id, selection); +} + +use helix_core::movement::{move_horizontally, move_vertically}; + +fn move_char_left(cx: &mut Context) { + move_impl(cx, move_horizontally, Direction::Backward, Movement::Move) +} + +fn move_char_right(cx: &mut Context) { + move_impl(cx, move_horizontally, Direction::Forward, Movement::Move) +} + +fn move_line_up(cx: &mut Context) { + move_impl(cx, move_vertically, Direction::Backward, Movement::Move) +} + +fn move_line_down(cx: &mut Context) { + move_impl(cx, move_vertically, Direction::Forward, Movement::Move) +} + +fn extend_char_left(cx: &mut Context) { + move_impl(cx, move_horizontally, Direction::Backward, Movement::Extend) +} + +fn extend_char_right(cx: &mut Context) { + move_impl(cx, move_horizontally, Direction::Forward, Movement::Extend) +} + +fn extend_line_up(cx: &mut Context) { + move_impl(cx, move_vertically, Direction::Backward, Movement::Extend) +} + +fn extend_line_down(cx: &mut Context) { + move_impl(cx, move_vertically, Direction::Forward, Movement::Extend) +} + +fn goto_line_end_impl(view: &mut View, doc: &mut Document, movement: Movement) { + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + let line_start = text.line_to_char(line); + + let pos = graphemes::prev_grapheme_boundary(text, line_end_char_index(&text, line)) + .max(line_start); + + range.put_cursor(text, pos, movement == Movement::Extend) + }); + doc.set_selection(view.id, selection); +} + +fn goto_line_end(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + goto_line_end_impl( + view, + doc, + if cx.editor.mode == Mode::Select { + Movement::Extend + } else { + Movement::Move + }, + ) +} + +fn extend_to_line_end(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + goto_line_end_impl(view, doc, Movement::Extend) +} + +fn goto_line_end_newline_impl(view: &mut View, doc: &mut Document, movement: Movement) { + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + let pos = line_end_char_index(&text, line); + + range.put_cursor(text, pos, movement == Movement::Extend) + }); + doc.set_selection(view.id, selection); +} + +fn goto_line_end_newline(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + goto_line_end_newline_impl( + view, + doc, + if cx.editor.mode == Mode::Select { + Movement::Extend + } else { + Movement::Move + }, + ) +} + +fn extend_to_line_end_newline(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + goto_line_end_newline_impl(view, doc, Movement::Extend) +} + +fn goto_line_start_impl(view: &mut View, doc: &mut Document, movement: Movement) { + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + + // adjust to start of the line + let pos = text.line_to_char(line); + range.put_cursor(text, pos, movement == Movement::Extend) + }); + doc.set_selection(view.id, selection); +} + +fn goto_line_start(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + goto_line_start_impl( + view, + doc, + if cx.editor.mode == Mode::Select { + Movement::Extend + } else { + Movement::Move + }, + ) +} + +fn goto_next_buffer(cx: &mut Context) { + goto_buffer(cx.editor, Direction::Forward); +} + +fn goto_previous_buffer(cx: &mut Context) { + goto_buffer(cx.editor, Direction::Backward); +} + +fn goto_buffer(editor: &mut Editor, direction: Direction) { + let current = view!(editor).doc; + + let id = match direction { + Direction::Forward => { + let iter = editor.documents.keys(); + let mut iter = iter.skip_while(|id| *id != ¤t); + iter.next(); // skip current item + iter.next().or_else(|| editor.documents.keys().next()) + } + Direction::Backward => { + let iter = editor.documents.keys(); + let mut iter = iter.rev().skip_while(|id| *id != ¤t); + iter.next(); // skip current item + iter.next().or_else(|| editor.documents.keys().rev().next()) + } + } + .unwrap(); + + let id = *id; + + editor.switch(id, Action::Replace); +} + +fn extend_to_line_start(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + goto_line_start_impl(view, doc, Movement::Extend) +} + +fn kill_to_line_start(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + let first_char = text.line_to_char(line); + let anchor = range.cursor(text); + let head = if anchor == first_char && line != 0 { + // select until previous line + line_end_char_index(&text, line - 1) + } else if let Some(pos) = find_first_non_whitespace_char(text.line(line)) { + if first_char + pos < anchor { + // select until first non-blank in line if cursor is after it + first_char + pos + } else { + // select until start of line + first_char + } + } else { + // select until start of line + first_char + }; + Range::new(head, anchor) + }); + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); +} + +fn kill_to_line_end(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + let line_end_pos = line_end_char_index(&text, line); + let pos = range.cursor(text); + + let mut new_range = range.put_cursor(text, line_end_pos, true); + // don't want to remove the line separator itself if the cursor doesn't reach the end of line. + if pos != line_end_pos { + new_range.head = line_end_pos; + } + new_range + }); + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); +} + +fn goto_first_nonwhitespace(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + + if let Some(pos) = find_first_non_whitespace_char(text.line(line)) { + let pos = pos + text.line_to_char(line); + range.put_cursor(text, pos, cx.editor.mode == Mode::Select) + } else { + range + } + }); + doc.set_selection(view.id, selection); +} + +fn trim_selections(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let ranges: SmallVec<[Range; 1]> = doc + .selection(view.id) + .iter() + .filter_map(|range| { + if range.is_empty() || range.slice(text).chars().all(|ch| ch.is_whitespace()) { + return None; + } + let mut start = range.from(); + let mut end = range.to(); + start = movement::skip_while(text, start, |x| x.is_whitespace()).unwrap_or(start); + end = movement::backwards_skip_while(text, end, |x| x.is_whitespace()).unwrap_or(end); + Some(Range::new(start, end).with_direction(range.direction())) + }) + .collect(); + + if !ranges.is_empty() { + let primary = doc.selection(view.id).primary(); + let idx = ranges + .iter() + .position(|range| range.overlaps(&primary)) + .unwrap_or(ranges.len() - 1); + doc.set_selection(view.id, Selection::new(ranges, idx)); + } else { + collapse_selection(cx); + keep_primary_selection(cx); + }; +} + +// align text in selection +fn align_selections(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + + let tab_width = doc.tab_width(); + let mut column_widths: Vec> = Vec::new(); + let mut last_line = text.len_lines() + 1; + let mut col = 0; + + for range in selection { + let coords = visual_coords_at_pos(text, range.head, tab_width); + let anchor_coords = visual_coords_at_pos(text, range.anchor, tab_width); + + if coords.row != anchor_coords.row { + cx.editor + .set_error("align cannot work with multi line selections"); + return; + } + + col = if coords.row == last_line { col + 1 } else { 0 }; + + if col >= column_widths.len() { + column_widths.push(Vec::new()); + } + column_widths[col].push((range.from(), coords.col)); + + last_line = coords.row; + } + + let mut changes = Vec::with_capacity(selection.len()); + + // Account for changes on each row + let len = column_widths.first().map(|cols| cols.len()).unwrap_or(0); + let mut offs = vec![0; len]; + + for col in column_widths { + let max_col = col + .iter() + .enumerate() + .map(|(row, (_, cursor))| *cursor + offs[row]) + .max() + .unwrap_or(0); + + for (row, (insert_pos, last_col)) in col.into_iter().enumerate() { + let ins_count = max_col - (last_col + offs[row]); + + if ins_count == 0 { + continue; + } + + offs[row] += ins_count; + + changes.push((insert_pos, insert_pos, Some(" ".repeat(ins_count).into()))); + } + } + + // The changeset has to be sorted + changes.sort_unstable_by_key(|(from, _, _)| *from); + + let transaction = Transaction::change(doc.text(), changes.into_iter()); + apply_transaction(&transaction, doc, view); +} + +fn goto_window(cx: &mut Context, align: Align) { + let count = cx.count() - 1; + let config = cx.editor.config(); + let (view, doc) = current!(cx.editor); + + let height = view.inner_height(); + + // respect user given count if any + // - 1 so we have at least one gap in the middle. + // a height of 6 with padding of 3 on each side will keep shifting the view back and forth + // as we type + let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2); + + let last_line = view.last_line(doc); + + let line = match align { + Align::Top => view.offset.row + scrolloff + count, + Align::Center => view.offset.row + ((last_line - view.offset.row) / 2), + Align::Bottom => last_line.saturating_sub(scrolloff + count), + } + .max(view.offset.row + scrolloff) + .min(last_line.saturating_sub(scrolloff)); + + let pos = doc.text().line_to_char(line); + let text = doc.text().slice(..); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); + doc.set_selection(view.id, selection); +} + +fn goto_window_top(cx: &mut Context) { + goto_window(cx, Align::Top) +} + +fn goto_window_center(cx: &mut Context) { + goto_window(cx, Align::Center) +} + +fn goto_window_bottom(cx: &mut Context) { + goto_window(cx, Align::Bottom) +} + +fn move_word_impl(cx: &mut Context, move_fn: F) +where + F: Fn(RopeSlice, Range, usize) -> Range, +{ + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| move_fn(text, range, count)); + doc.set_selection(view.id, selection); +} + +fn move_next_word_start(cx: &mut Context) { + move_word_impl(cx, movement::move_next_word_start) +} + +fn move_prev_word_start(cx: &mut Context) { + move_word_impl(cx, movement::move_prev_word_start) +} + +fn move_prev_word_end(cx: &mut Context) { + move_word_impl(cx, movement::move_prev_word_end) +} + +fn move_next_word_end(cx: &mut Context) { + move_word_impl(cx, movement::move_next_word_end) +} + +fn move_next_long_word_start(cx: &mut Context) { + move_word_impl(cx, movement::move_next_long_word_start) +} + +fn move_prev_long_word_start(cx: &mut Context) { + move_word_impl(cx, movement::move_prev_long_word_start) +} + +fn move_next_long_word_end(cx: &mut Context) { + move_word_impl(cx, movement::move_next_long_word_end) +} + +fn goto_para_impl(cx: &mut Context, move_fn: F) +where + F: Fn(RopeSlice, Range, usize, Movement) -> Range + 'static, +{ + let count = cx.count(); + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + let behavior = if editor.mode == Mode::Select { + Movement::Extend + } else { + Movement::Move + }; + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| move_fn(text, range, count, behavior)); + doc.set_selection(view.id, selection); + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn goto_prev_paragraph(cx: &mut Context) { + goto_para_impl(cx, movement::move_prev_paragraph) +} + +fn goto_next_paragraph(cx: &mut Context) { + goto_para_impl(cx, movement::move_next_paragraph) +} + +fn goto_file_start(cx: &mut Context) { + if cx.count.is_some() { + goto_line(cx); + } else { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, 0, cx.editor.mode == Mode::Select)); + push_jump(view, doc); + doc.set_selection(view.id, selection); + } +} + +fn goto_file_end(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let pos = doc.text().len_chars(); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); + push_jump(view, doc); + doc.set_selection(view.id, selection); +} + +fn goto_file(cx: &mut Context) { + goto_file_impl(cx, Action::Replace); +} + +fn goto_file_hsplit(cx: &mut Context) { + goto_file_impl(cx, Action::HorizontalSplit); +} + +fn goto_file_vsplit(cx: &mut Context) { + goto_file_impl(cx, Action::VerticalSplit); +} + +/// Goto files in selection. +fn goto_file_impl(cx: &mut Context, action: Action) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text(); + let selections = doc.selection(view.id); + let mut paths: Vec<_> = selections + .iter() + .map(|r| text.slice(r.from()..r.to()).to_string()) + .collect(); + let primary = selections.primary(); + // Checks whether there is only one selection with a width of 1 + if selections.len() == 1 && primary.len() == 1 { + let count = cx.count(); + let text_slice = text.slice(..); + // In this case it selects the WORD under the cursor + let current_word = textobject::textobject_word( + text_slice, + primary, + textobject::TextObject::Inside, + count, + true, + ); + // Trims some surrounding chars so that the actual file is opened. + let surrounding_chars: &[_] = &['\'', '"', '(', ')']; + paths.clear(); + paths.push( + current_word + .fragment(text_slice) + .trim_matches(surrounding_chars) + .to_string(), + ); + } + for sel in paths { + let p = sel.trim(); + if !p.is_empty() { + if let Err(e) = cx.editor.open(&PathBuf::from(p), action) { + cx.editor.set_error(format!("Open file failed: {:?}", e)); + } + } + } +} + +fn extend_word_impl(cx: &mut Context, extend_fn: F) +where + F: Fn(RopeSlice, Range, usize) -> Range, +{ + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let word = extend_fn(text, range, count); + let pos = word.cursor(text); + range.put_cursor(text, pos, true) + }); + doc.set_selection(view.id, selection); +} + +fn extend_next_word_start(cx: &mut Context) { + extend_word_impl(cx, movement::move_next_word_start) +} + +fn extend_prev_word_start(cx: &mut Context) { + extend_word_impl(cx, movement::move_prev_word_start) +} + +fn extend_next_word_end(cx: &mut Context) { + extend_word_impl(cx, movement::move_next_word_end) +} + +fn extend_prev_word_end(cx: &mut Context) { + extend_word_impl(cx, movement::move_prev_word_end) +} + +fn extend_next_long_word_start(cx: &mut Context) { + extend_word_impl(cx, movement::move_next_long_word_start) +} + +fn extend_prev_long_word_start(cx: &mut Context) { + extend_word_impl(cx, movement::move_prev_long_word_start) +} + +fn extend_next_long_word_end(cx: &mut Context) { + extend_word_impl(cx, movement::move_next_long_word_end) +} + +fn will_find_char(cx: &mut Context, search_fn: F, inclusive: bool, extend: bool) +where + F: Fn(RopeSlice, char, usize, usize, bool) -> Option + 'static, +{ + // TODO: count is reset to 1 before next key so we move it into the closure here. + // Would be nice to carry over. + let count = cx.count(); + + // need to wait for next key + // TODO: should this be done by grapheme rather than char? For example, + // we can't properly handle the line-ending CRLF case here in terms of char. + cx.on_next_key(move |cx, event| { + let ch = match event { + KeyEvent { + code: KeyCode::Enter, + .. + } => + // TODO: this isn't quite correct when CRLF is involved. + // This hack will work in most cases, since documents don't + // usually mix line endings. But we should fix it eventually + // anyway. + { + doc!(cx.editor).line_ending.as_str().chars().next().unwrap() + } + + KeyEvent { + code: KeyCode::Tab, .. + } => '\t', + + KeyEvent { + code: KeyCode::Char(ch), + .. + } => ch, + _ => return, + }; + + find_char_impl(cx.editor, &search_fn, inclusive, extend, ch, count); + cx.editor.last_motion = Some(Motion(Box::new(move |editor: &mut Editor| { + find_char_impl(editor, &search_fn, inclusive, true, ch, 1); + }))); + }) +} + +// + +#[inline] +fn find_char_impl( + editor: &mut Editor, + search_fn: &F, + inclusive: bool, + extend: bool, + char_matcher: M, + count: usize, +) where + F: Fn(RopeSlice, M, usize, usize, bool) -> Option + 'static, +{ + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + // TODO: use `Range::cursor()` here instead. However, that works in terms of + // graphemes, whereas this function doesn't yet. So we're doing the same logic + // here, but just in terms of chars instead. + let search_start_pos = if range.anchor < range.head { + range.head - 1 + } else { + range.head + }; + + search_fn(text, char_matcher, search_start_pos, count, inclusive).map_or(range, |pos| { + if extend { + range.put_cursor(text, pos, true) + } else { + Range::point(range.cursor(text)).put_cursor(text, pos, true) + } + }) + }); + doc.set_selection(view.id, selection); +} + +fn find_next_char_impl( + text: RopeSlice, + ch: char, + pos: usize, + n: usize, + inclusive: bool, +) -> Option { + let pos = (pos + 1).min(text.len_chars()); + if inclusive { + search::find_nth_next(text, ch, pos, n) + } else { + let n = match text.get_char(pos) { + Some(next_ch) if next_ch == ch => n + 1, + _ => n, + }; + search::find_nth_next(text, ch, pos, n).map(|n| n.saturating_sub(1)) + } +} + +fn find_prev_char_impl( + text: RopeSlice, + ch: char, + pos: usize, + n: usize, + inclusive: bool, +) -> Option { + if inclusive { + search::find_nth_prev(text, ch, pos, n) + } else { + let n = match text.get_char(pos.saturating_sub(1)) { + Some(next_ch) if next_ch == ch => n + 1, + _ => n, + }; + search::find_nth_prev(text, ch, pos, n).map(|n| (n + 1).min(text.len_chars())) + } +} + +fn find_till_char(cx: &mut Context) { + will_find_char(cx, find_next_char_impl, false, false) +} + +fn find_next_char(cx: &mut Context) { + will_find_char(cx, find_next_char_impl, true, false) +} + +fn extend_till_char(cx: &mut Context) { + will_find_char(cx, find_next_char_impl, false, true) +} + +fn extend_next_char(cx: &mut Context) { + will_find_char(cx, find_next_char_impl, true, true) +} + +fn till_prev_char(cx: &mut Context) { + will_find_char(cx, find_prev_char_impl, false, false) +} + +fn find_prev_char(cx: &mut Context) { + will_find_char(cx, find_prev_char_impl, true, false) +} + +fn extend_till_prev_char(cx: &mut Context) { + will_find_char(cx, find_prev_char_impl, false, true) +} + +fn extend_prev_char(cx: &mut Context) { + will_find_char(cx, find_prev_char_impl, true, true) +} + +fn repeat_last_motion(cx: &mut Context) { + let count = cx.count(); + let last_motion = cx.editor.last_motion.take(); + if let Some(m) = &last_motion { + for _ in 0..count { + m.run(cx.editor); + } + cx.editor.last_motion = last_motion; + } +} + +fn replace(cx: &mut Context) { + let mut buf = [0u8; 4]; // To hold utf8 encoded char. + + // need to wait for next key + cx.on_next_key(move |cx, event| { + let (view, doc) = current!(cx.editor); + let ch: Option<&str> = match event { + KeyEvent { + code: KeyCode::Char(ch), + .. + } => Some(ch.encode_utf8(&mut buf[..])), + KeyEvent { + code: KeyCode::Enter, + .. + } => Some(doc.line_ending.as_str()), + KeyEvent { + code: KeyCode::Tab, .. + } => Some("\t"), + _ => None, + }; + + let selection = doc.selection(view.id); + + if let Some(ch) = ch { + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + if !range.is_empty() { + let text: String = + RopeGraphemes::new(doc.text().slice(range.from()..range.to())) + .map(|g| { + let cow: Cow = g.into(); + if str_is_line_ending(&cow) { + cow + } else { + ch.into() + } + }) + .collect(); + + (range.from(), range.to(), Some(text.into())) + } else { + // No change. + (range.from(), range.to(), None) + } + }); + + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); + } + }) +} + +fn switch_case_impl(cx: &mut Context, change_fn: F) +where + F: Fn(RopeSlice) -> Tendril, +{ + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + let text: Tendril = change_fn(range.slice(doc.text().slice(..))); + + (range.from(), range.to(), Some(text)) + }); + + apply_transaction(&transaction, doc, view); +} + +fn switch_case(cx: &mut Context) { + switch_case_impl(cx, |string| { + string + .chars() + .flat_map(|ch| { + if ch.is_lowercase() { + ch.to_uppercase().collect() + } else if ch.is_uppercase() { + ch.to_lowercase().collect() + } else { + vec![ch] + } + }) + .collect() + }); +} + +fn switch_to_uppercase(cx: &mut Context) { + switch_case_impl(cx, |string| { + string.chunks().map(|chunk| chunk.to_uppercase()).collect() + }); +} + +fn switch_to_lowercase(cx: &mut Context) { + switch_case_impl(cx, |string| { + string.chunks().map(|chunk| chunk.to_lowercase()).collect() + }); +} + +pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { + use Direction::*; + let config = cx.editor.config(); + let (view, doc) = current!(cx.editor); + + let range = doc.selection(view.id).primary(); + let text = doc.text().slice(..); + + let cursor = visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); + let doc_last_line = doc.text().len_lines().saturating_sub(1); + + let last_line = view.last_line(doc); + + if direction == Backward && view.offset.row == 0 + || direction == Forward && last_line == doc_last_line + { + return; + } + + let height = view.inner_height(); + + let scrolloff = config.scrolloff.min(height / 2); + + view.offset.row = match direction { + Forward => view.offset.row + offset, + Backward => view.offset.row.saturating_sub(offset), + } + .min(doc_last_line); + + // recalculate last line + let last_line = view.last_line(doc); + + // clamp into viewport + let line = cursor + .row + .max(view.offset.row + scrolloff) + .min(last_line.saturating_sub(scrolloff)); + + // If cursor needs moving, replace primary selection + if line != cursor.row { + let head = pos_at_visual_coords(text, Position::new(line, cursor.col), doc.tab_width()); // this func will properly truncate to line end + + let anchor = if cx.editor.mode == Mode::Select { + range.anchor + } else { + head + }; + + // replace primary selection with an empty selection at cursor pos + let prim_sel = Range::new(anchor, head); + let mut sel = doc.selection(view.id).clone(); + let idx = sel.primary_index(); + sel = sel.replace(idx, prim_sel); + doc.set_selection(view.id, sel); + } +} + +fn page_up(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height(); + scroll(cx, offset, Direction::Backward); +} + +fn page_down(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height(); + scroll(cx, offset, Direction::Forward); +} + +fn half_page_up(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height() / 2; + scroll(cx, offset, Direction::Backward); +} + +fn half_page_down(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height() / 2; + scroll(cx, offset, Direction::Forward); +} + +fn copy_selection_on_line(cx: &mut Context, direction: Direction) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let mut ranges = SmallVec::with_capacity(selection.ranges().len() * (count + 1)); + ranges.extend_from_slice(selection.ranges()); + let mut primary_index = 0; + for range in selection.iter() { + let is_primary = *range == selection.primary(); + + // The range is always head exclusive + let (head, anchor) = if range.anchor < range.head { + (range.head - 1, range.anchor) + } else { + (range.head, range.anchor.saturating_sub(1)) + }; + + let tab_width = doc.tab_width(); + + let head_pos = visual_coords_at_pos(text, head, tab_width); + let anchor_pos = visual_coords_at_pos(text, anchor, tab_width); + + let height = std::cmp::max(head_pos.row, anchor_pos.row) + - std::cmp::min(head_pos.row, anchor_pos.row) + + 1; + + if is_primary { + primary_index = ranges.len(); + } + ranges.push(*range); + + let mut sels = 0; + let mut i = 0; + while sels < count { + let offset = (i + 1) * height; + + let anchor_row = match direction { + Direction::Forward => anchor_pos.row + offset, + Direction::Backward => anchor_pos.row.saturating_sub(offset), + }; + + let head_row = match direction { + Direction::Forward => head_pos.row + offset, + Direction::Backward => head_pos.row.saturating_sub(offset), + }; + + if anchor_row >= text.len_lines() || head_row >= text.len_lines() { + break; + } + + let anchor = + pos_at_visual_coords(text, Position::new(anchor_row, anchor_pos.col), tab_width); + let head = pos_at_visual_coords(text, Position::new(head_row, head_pos.col), tab_width); + + // skip lines that are too short + if visual_coords_at_pos(text, anchor, tab_width).col == anchor_pos.col + && visual_coords_at_pos(text, head, tab_width).col == head_pos.col + { + if is_primary { + primary_index = ranges.len(); + } + // This is Range::new(anchor, head), but it will place the cursor on the correct column + ranges.push(Range::point(anchor).put_cursor(text, head, true)); + sels += 1; + } + + i += 1; + } + } + + let selection = Selection::new(ranges, primary_index); + doc.set_selection(view.id, selection); +} + +fn copy_selection_on_prev_line(cx: &mut Context) { + copy_selection_on_line(cx, Direction::Backward) +} + +fn copy_selection_on_next_line(cx: &mut Context) { + copy_selection_on_line(cx, Direction::Forward) +} + +fn select_all(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let end = doc.text().len_chars(); + doc.set_selection(view.id, Selection::single(0, end)) +} + +fn select_regex(cx: &mut Context) { + let reg = cx.register.unwrap_or('/'); + ui::regex_prompt( + cx, + "select:".into(), + Some(reg), + ui::completers::none, + move |editor, regex, event| { + let (view, doc) = current!(editor); + if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { + return; + } + let text = doc.text().slice(..); + if let Some(selection) = + selection::select_on_matches(text, doc.selection(view.id), ®ex) + { + doc.set_selection(view.id, selection); + } + }, + ); +} + +fn split_selection(cx: &mut Context) { + let reg = cx.register.unwrap_or('/'); + ui::regex_prompt( + cx, + "split:".into(), + Some(reg), + ui::completers::none, + move |editor, regex, event| { + let (view, doc) = current!(editor); + if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { + return; + } + let text = doc.text().slice(..); + let selection = selection::split_on_matches(text, doc.selection(view.id), ®ex); + doc.set_selection(view.id, selection); + }, + ); +} + +fn split_selection_on_newline(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + // only compile the regex once + #[allow(clippy::trivial_regex)] + static REGEX: Lazy = + Lazy::new(|| Regex::new(r"\r\n|[\n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}]").unwrap()); + let selection = selection::split_on_matches(text, doc.selection(view.id), ®EX); + doc.set_selection(view.id, selection); +} + +#[allow(clippy::too_many_arguments)] +fn search_impl( + editor: &mut Editor, + contents: &str, + regex: &Regex, + movement: Movement, + direction: Direction, + scrolloff: usize, + wrap_around: bool, + show_warnings: bool, +) { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + + // Get the right side of the primary block cursor for forward search, or the + // grapheme before the start of the selection for reverse search. + let start = match direction { + Direction::Forward => text.char_to_byte(graphemes::ensure_grapheme_boundary_next( + text, + selection.primary().to(), + )), + Direction::Backward => text.char_to_byte(graphemes::ensure_grapheme_boundary_prev( + text, + selection.primary().from(), + )), + }; + + // A regex::Match returns byte-positions in the str. In the case where we + // do a reverse search and wraparound to the end, we don't need to search + // the text before the current cursor position for matches, but by slicing + // it out, we need to add it back to the position of the selection. + let mut offset = 0; + + // use find_at to find the next match after the cursor, loop around the end + // Careful, `Regex` uses `bytes` as offsets, not character indices! + let mut mat = match direction { + Direction::Forward => regex.find_at(contents, start), + Direction::Backward => regex.find_iter(&contents[..start]).last(), + }; + + if mat.is_none() { + if wrap_around { + mat = match direction { + Direction::Forward => regex.find(contents), + Direction::Backward => { + offset = start; + regex.find_iter(&contents[start..]).last() + } + }; + } + if show_warnings { + if wrap_around && mat.is_some() { + editor.set_status("Wrapped around document"); + } else { + editor.set_error("No more matches"); + } + } + } + + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + + if let Some(mat) = mat { + let start = text.byte_to_char(mat.start() + offset); + let end = text.byte_to_char(mat.end() + offset); + + if end == 0 { + // skip empty matches that don't make sense + return; + } + + // Determine range direction based on the primary range + let primary = selection.primary(); + let range = Range::new(start, end).with_direction(primary.direction()); + + let selection = match movement { + Movement::Extend => selection.clone().push(range), + Movement::Move => selection.clone().replace(selection.primary_index(), range), + }; + + doc.set_selection(view.id, selection); + // TODO: is_cursor_in_view does the same calculation as ensure_cursor_in_view + if view.is_cursor_in_view(doc, 0) { + view.ensure_cursor_in_view(doc, scrolloff); + } else { + align_view(doc, view, Align::Center) + } + }; +} + +fn search_completions(cx: &mut Context, reg: Option) -> Vec { + let mut items = reg + .and_then(|reg| cx.editor.registers.get(reg)) + .map_or(Vec::new(), |reg| reg.read().iter().take(200).collect()); + items.sort_unstable(); + items.dedup(); + items.into_iter().cloned().collect() +} + +fn search(cx: &mut Context) { + searcher(cx, Direction::Forward) +} + +fn rsearch(cx: &mut Context) { + searcher(cx, Direction::Backward) +} + +fn searcher(cx: &mut Context, direction: Direction) { + let reg = cx.register.unwrap_or('/'); + let config = cx.editor.config(); + let scrolloff = config.scrolloff; + let wrap_around = config.search.wrap_around; + + let doc = doc!(cx.editor); + + // TODO: could probably share with select_on_matches? + + // HAXX: sadly we can't avoid allocating a single string for the whole buffer since we can't + // feed chunks into the regex yet + let contents = doc.text().slice(..).to_string(); + let completions = search_completions(cx, Some(reg)); + + ui::regex_prompt( + cx, + "search:".into(), + Some(reg), + move |_editor: &Editor, input: &str| { + completions + .iter() + .filter(|comp| comp.starts_with(input)) + .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) + .collect() + }, + move |editor, regex, event| { + if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { + return; + } + search_impl( + editor, + &contents, + ®ex, + Movement::Move, + direction, + scrolloff, + wrap_around, + false, + ); + }, + ); +} + +fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) { + let count = cx.count(); + let config = cx.editor.config(); + let scrolloff = config.scrolloff; + let (_, doc) = current!(cx.editor); + let registers = &cx.editor.registers; + if let Some(query) = registers.read('/').and_then(|query| query.last()) { + let contents = doc.text().slice(..).to_string(); + let search_config = &config.search; + let case_insensitive = if search_config.smart_case { + !query.chars().any(char::is_uppercase) + } else { + false + }; + let wrap_around = search_config.wrap_around; + if let Ok(regex) = RegexBuilder::new(query) + .case_insensitive(case_insensitive) + .multi_line(true) + .build() + { + for _ in 0..count { + search_impl( + cx.editor, + &contents, + ®ex, + movement, + direction, + scrolloff, + wrap_around, + true, + ); + } + } else { + let error = format!("Invalid regex: {}", query); + cx.editor.set_error(error); + } + } +} + +fn search_next(cx: &mut Context) { + search_next_or_prev_impl(cx, Movement::Move, Direction::Forward); +} + +fn search_prev(cx: &mut Context) { + search_next_or_prev_impl(cx, Movement::Move, Direction::Backward); +} +fn extend_search_next(cx: &mut Context) { + search_next_or_prev_impl(cx, Movement::Extend, Direction::Forward); +} + +fn extend_search_prev(cx: &mut Context) { + search_next_or_prev_impl(cx, Movement::Extend, Direction::Backward); +} + +fn search_selection(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let contents = doc.text().slice(..); + + let regex = doc + .selection(view.id) + .iter() + .map(|selection| regex::escape(&selection.fragment(contents))) + .collect::>() // Collect into hashset to deduplicate identical regexes + .into_iter() + .collect::>() + .join("|"); + + let msg = format!("register '{}' set to '{}'", '/', ®ex); + cx.editor.registers.push('/', regex); + cx.editor.set_status(msg); +} + +fn make_search_word_bounded(cx: &mut Context) { + let regex = match cx.editor.registers.last('/') { + Some(regex) => regex, + None => return, + }; + let start_anchored = regex.starts_with("\\b"); + let end_anchored = regex.ends_with("\\b"); + + if start_anchored && end_anchored { + return; + } + + let mut new_regex = String::with_capacity( + regex.len() + if start_anchored { 0 } else { 2 } + if end_anchored { 0 } else { 2 }, + ); + + if !start_anchored { + new_regex.push_str("\\b"); + } + new_regex.push_str(regex); + if !end_anchored { + new_regex.push_str("\\b"); + } + + let msg = format!("register '{}' set to '{}'", '/', &new_regex); + cx.editor.registers.push('/', new_regex); + cx.editor.set_status(msg); +} + +fn global_search(cx: &mut Context) { + #[derive(Debug)] + struct FileResult { + path: PathBuf, + /// 0 indexed lines + line_num: usize, + } + + impl FileResult { + fn new(path: &Path, line_num: usize) -> Self { + Self { + path: path.to_path_buf(), + line_num, + } + } + } + + impl ui::menu::Item for FileResult { + type Data = Option; + + fn label(&self, current_path: &Self::Data) -> Spans { + let relative_path = helix_core::path::get_relative_path(&self.path) + .to_string_lossy() + .into_owned(); + if current_path + .as_ref() + .map(|p| p == &self.path) + .unwrap_or(false) + { + format!("{} (*)", relative_path).into() + } else { + relative_path.into() + } + } + } + + let (all_matches_sx, all_matches_rx) = tokio::sync::mpsc::unbounded_channel::(); + let config = cx.editor.config(); + let smart_case = config.search.smart_case; + let file_picker_config = config.file_picker.clone(); + + let reg = cx.register.unwrap_or('/'); + + let completions = search_completions(cx, Some(reg)); + ui::regex_prompt( + cx, + "global-search:".into(), + Some(reg), + move |_editor: &Editor, input: &str| { + completions + .iter() + .filter(|comp| comp.starts_with(input)) + .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) + .collect() + }, + move |_editor, regex, event| { + if event != PromptEvent::Validate { + return; + } + + if let Ok(matcher) = RegexMatcherBuilder::new() + .case_smart(smart_case) + .build(regex.as_str()) + { + let searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .build(); + + let search_root = std::env::current_dir() + .expect("Global search error: Failed to get current dir"); + WalkBuilder::new(search_root) + .hidden(file_picker_config.hidden) + .parents(file_picker_config.parents) + .ignore(file_picker_config.ignore) + .follow_links(file_picker_config.follow_symlinks) + .git_ignore(file_picker_config.git_ignore) + .git_global(file_picker_config.git_global) + .git_exclude(file_picker_config.git_exclude) + .max_depth(file_picker_config.max_depth) + // We always want to ignore the .git directory, otherwise if + // `ignore` is turned off above, we end up with a lot of noise + // in our picker. + .filter_entry(|entry| entry.file_name() != ".git") + .build_parallel() + .run(|| { + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let all_matches_sx = all_matches_sx.clone(); + Box::new(move |entry: Result| -> WalkState { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; + + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; + + let result = searcher.search_path( + &matcher, + entry.path(), + sinks::UTF8(|line_num, _| { + all_matches_sx + .send(FileResult::new(entry.path(), line_num as usize - 1)) + .unwrap(); + + Ok(true) + }), + ); + + if let Err(err) = result { + log::error!( + "Global search error: {}, {}", + entry.path().display(), + err + ); + } + WalkState::Continue + }) + }); + } else { + // Otherwise do nothing + // log::warn!("Global Search Invalid Pattern") + } + }, + ); + + let current_path = doc_mut!(cx.editor).path().cloned(); + + let show_picker = async move { + let all_matches: Vec = + UnboundedReceiverStream::new(all_matches_rx).collect().await; + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + if all_matches.is_empty() { + editor.set_status("No matches found"); + return; + } + + let picker = FilePicker::new( + all_matches, + current_path, + move |cx, FileResult { path, line_num }, action| { + match cx.editor.open(path, action) { + Ok(_) => {} + Err(e) => { + cx.editor.set_error(format!( + "Failed to open file '{}': {}", + path.display(), + e + )); + return; + } + } + + let line_num = *line_num; + let (view, doc) = current!(cx.editor); + let text = doc.text(); + let start = text.line_to_char(line_num); + let end = text.line_to_char((line_num + 1).min(text.len_lines())); + + doc.set_selection(view.id, Selection::single(start, end)); + align_view(doc, view, Align::Center); + }, + |_editor, FileResult { path, line_num }| { + Some((path.clone().into(), Some((*line_num, *line_num)))) + }, + ); + compositor.push(Box::new(overlayed(picker))); + }, + )); + Ok(call) + }; + cx.jobs.callback(show_picker); +} + +enum Extend { + Above, + Below, +} + +fn extend_line(cx: &mut Context) { + let (view, doc) = current_ref!(cx.editor); + let extend = match doc.selection(view.id).primary().direction() { + Direction::Forward => Extend::Below, + Direction::Backward => Extend::Above, + }; + extend_line_impl(cx, extend); +} + +fn extend_line_below(cx: &mut Context) { + extend_line_impl(cx, Extend::Below); +} + +fn extend_line_above(cx: &mut Context) { + extend_line_impl(cx, Extend::Above); +} + +fn extend_line_impl(cx: &mut Context, extend: Extend) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + + let text = doc.text(); + let selection = doc.selection(view.id).clone().transform(|range| { + let (start_line, end_line) = range.line_range(text.slice(..)); + + let start = text.line_to_char(match extend { + Extend::Above => start_line.saturating_sub(count - 1), + Extend::Below => start_line, + }); + let end = text.line_to_char( + match extend { + Extend::Above => end_line + 1, // the start of next line + Extend::Below => end_line + count, + } + .min(text.len_lines()), + ); + + // extend to previous/next line if current line is selected + let (anchor, head) = if range.from() == start && range.to() == end { + match extend { + Extend::Above => (end, text.line_to_char(start_line.saturating_sub(count))), + Extend::Below => ( + start, + text.line_to_char((end_line + count + 1).min(text.len_lines())), + ), + } + } else { + match extend { + Extend::Above => (end, start), + Extend::Below => (start, end), + } + }; + + Range::new(anchor, head) + }); + + doc.set_selection(view.id, selection); +} + +fn extend_to_line_bounds(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + doc.set_selection( + view.id, + doc.selection(view.id).clone().transform(|range| { + let text = doc.text(); + + let (start_line, end_line) = range.line_range(text.slice(..)); + let start = text.line_to_char(start_line); + let end = text.line_to_char((end_line + 1).min(text.len_lines())); + + Range::new(start, end).with_direction(range.direction()) + }), + ); +} + +fn shrink_to_line_bounds(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + doc.set_selection( + view.id, + doc.selection(view.id).clone().transform(|range| { + let text = doc.text(); + + let (start_line, end_line) = range.line_range(text.slice(..)); + + // Do nothing if the selection is within one line to prevent + // conditional logic for the behavior of this command + if start_line == end_line { + return range; + } + + let mut start = text.line_to_char(start_line); + + // line_to_char gives us the start position of the line, so + // we need to get the start position of the next line. In + // the editor, this will correspond to the cursor being on + // the EOL whitespace character, which is what we want. + let mut end = text.line_to_char((end_line + 1).min(text.len_lines())); + + if start != range.from() { + start = text.line_to_char((start_line + 1).min(text.len_lines())); + } + + if end != range.to() { + end = text.line_to_char(end_line); + } + + Range::new(start, end).with_direction(range.direction()) + }), + ); +} + +enum Operation { + Delete, + Change, +} + +fn delete_selection_impl(cx: &mut Context, op: Operation) { + let (view, doc) = current!(cx.editor); + + let selection = doc.selection(view.id); + + if cx.register != Some('_') { + // first yank the selection + let text = doc.text().slice(..); + let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); + let reg_name = cx.register.unwrap_or('"'); + cx.editor.registers.write(reg_name, values); + }; + + // then delete + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), None) + }); + apply_transaction(&transaction, doc, view); + + match op { + Operation::Delete => { + // exit select mode, if currently in select mode + exit_select_mode(cx); + } + Operation::Change => { + enter_insert_mode(cx); + } + } +} + +#[inline] +fn delete_selection_insert_mode(doc: &mut Document, view: &mut View, selection: &Selection) { + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), None) + }); + apply_transaction(&transaction, doc, view); +} + +fn delete_selection(cx: &mut Context) { + delete_selection_impl(cx, Operation::Delete); +} + +fn delete_selection_noyank(cx: &mut Context) { + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Delete); +} + +fn change_selection(cx: &mut Context) { + delete_selection_impl(cx, Operation::Change); +} + +fn change_selection_noyank(cx: &mut Context) { + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Change); +} + +fn collapse_selection(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let pos = range.cursor(text); + Range::new(pos, pos) + }); + doc.set_selection(view.id, selection); +} + +fn flip_selections(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.flip()); + doc.set_selection(view.id, selection); +} + +fn ensure_selections_forward(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let selection = doc + .selection(view.id) + .clone() + .transform(|r| r.with_direction(Direction::Forward)); + + doc.set_selection(view.id, selection); +} + +fn enter_insert_mode(cx: &mut Context) { + cx.editor.mode = Mode::Insert; +} + +// inserts at the start of each selection +fn insert_mode(cx: &mut Context) { + enter_insert_mode(cx); + let (view, doc) = current!(cx.editor); + + log::trace!( + "entering insert mode with sel: {:?}, text: {:?}", + doc.selection(view.id), + doc.text().to_string() + ); + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| Range::new(range.to(), range.from())); + + doc.set_selection(view.id, selection); +} + +// inserts at the end of each selection +fn append_mode(cx: &mut Context) { + enter_insert_mode(cx); + let (view, doc) = current!(cx.editor); + doc.restore_cursor = true; + let text = doc.text().slice(..); + + // Make sure there's room at the end of the document if the last + // selection butts up against it. + let end = text.len_chars(); + let last_range = doc + .selection(view.id) + .iter() + .last() + .expect("selection should always have at least one range"); + if !last_range.is_empty() && last_range.to() == end { + let transaction = Transaction::change( + doc.text(), + [(end, end, Some(doc.line_ending.as_str().into()))].into_iter(), + ); + apply_transaction(&transaction, doc, view); + } + + let selection = doc.selection(view.id).clone().transform(|range| { + Range::new( + range.from(), + graphemes::next_grapheme_boundary(doc.text().slice(..), range.to()), + ) + }); + doc.set_selection(view.id, selection); +} + +fn file_picker(cx: &mut Context) { + // We don't specify language markers, root will be the root of the current + // git repo or the current dir if we're not in a repo + let root = find_root(None, &[]); + let picker = ui::file_picker(root, &cx.editor.config()); + cx.push_layer(Box::new(overlayed(picker))); +} + +fn file_picker_in_current_directory(cx: &mut Context) { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("./")); + let picker = ui::file_picker(cwd, &cx.editor.config()); + cx.push_layer(Box::new(overlayed(picker))); +} + +fn toggle_or_focus_explorer(cx: &mut Context) { + cx.callback = Some(Box::new( + |compositor: &mut Compositor, cx: &mut compositor::Context| { + if let Some(editor) = compositor.find::() { + match editor.explorer.as_mut() { + Some(explore) => explore.content.focus(), + None => match ui::Explorer::new(cx) { + Ok(explore) => editor.explorer = Some(overlayed(explore)), + Err(err) => cx.editor.set_error(format!("{}", err)), + }, + } + } + }, + )); +} + +fn open_explorer_recursion(cx: &mut Context) { + cx.callback = Some(Box::new( + |compositor: &mut Compositor, cx: &mut compositor::Context| { + if let Some(editor) = compositor.find::() { + match ui::Explorer::new_explorer_recursion() { + Ok(explore) => editor.explorer = Some(overlayed(explore)), + Err(err) => cx.editor.set_error(format!("{}", err)), + } + } + }, + )); +} + +fn close_explorer(cx: &mut Context) { + cx.callback = Some(Box::new(|compositor: &mut Compositor, _| { + if let Some(editor) = compositor.find::() { + editor.explorer.take(); + } + })); +} + +fn buffer_picker(cx: &mut Context) { + let current = view!(cx.editor).doc; + + struct BufferMeta { + id: DocumentId, + path: Option, + is_modified: bool, + is_current: bool, + } + + impl ui::menu::Item for BufferMeta { + type Data = (); + + fn label(&self, _data: &Self::Data) -> Spans { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + }; + + let mut flags = Vec::new(); + if self.is_modified { + flags.push("+"); + } + if self.is_current { + flags.push("*"); + } + + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + format!("{} {}{}", self.id, path, flag).into() + } + } + + let new_meta = |doc: &Document| BufferMeta { + id: doc.id(), + path: doc.path().cloned(), + is_modified: doc.is_modified(), + is_current: doc.id() == current, + }; + + let picker = FilePicker::new( + cx.editor + .documents + .iter() + .map(|(_, doc)| new_meta(doc)) + .collect(), + (), + |cx, meta, action| { + cx.editor.switch(meta.id, action); + }, + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; + let &view_id = doc.selections().keys().next()?; + let line = doc + .selection(view_id) + .primary() + .cursor_line(doc.text().slice(..)); + Some((meta.id.into(), Some((line, line)))) + }, + ); + cx.push_layer(Box::new(overlayed(picker))); +} + +fn jumplist_picker(cx: &mut Context) { + struct JumpMeta { + id: DocumentId, + path: Option, + selection: Selection, + text: String, + is_current: bool, + } + + impl ui::menu::Item for JumpMeta { + type Data = (); + + fn label(&self, _data: &Self::Data) -> Spans { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + }; + + let mut flags = Vec::new(); + if self.is_current { + flags.push("*"); + } + + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + format!("{} {}{} {}", self.id, path, flag, self.text).into() + } + } + + let new_meta = |view: &View, doc_id: DocumentId, selection: Selection| { + let doc = &cx.editor.documents.get(&doc_id); + let text = doc.map_or("".into(), |d| { + selection + .fragments(d.text().slice(..)) + .map(Cow::into_owned) + .collect::>() + .join(" ") + }); + + JumpMeta { + id: doc_id, + path: doc.and_then(|d| d.path().cloned()), + selection, + text, + is_current: view.doc == doc_id, + } + }; + + let picker = FilePicker::new( + cx.editor + .tree + .views() + .flat_map(|(view, _)| { + view.jumps + .iter() + .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) + }) + .collect(), + (), + |cx, meta, action| { + cx.editor.switch(meta.id, action); + let (view, doc) = current!(cx.editor); + doc.set_selection(view.id, meta.selection.clone()); + }, + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; + let line = meta.selection.primary().cursor_line(doc.text().slice(..)); + Some((meta.path.clone()?.into(), Some((line, line)))) + }, + ); + cx.push_layer(Box::new(overlayed(picker))); +} + +impl ui::menu::Item for MappableCommand { + type Data = ReverseKeymap; + + fn label(&self, keymap: &Self::Data) -> Spans { + let fmt_binding = |bindings: &Vec>| -> String { + bindings.iter().fold(String::new(), |mut acc, bind| { + if !acc.is_empty() { + acc.push(' '); + } + for key in bind { + acc.push_str(&key.key_sequence_format()); + } + acc + }) + }; + + match self { + MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { + Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [:{}]", doc, name).into(), + }, + MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { + Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [{}]", doc, name).into(), + }, + } + } +} + +pub fn command_palette(cx: &mut Context) { + cx.callback = Some(Box::new( + move |compositor: &mut Compositor, cx: &mut compositor::Context| { + let keymap = compositor.find::().unwrap().keymaps.map() + [&cx.editor.mode] + .reverse_map(); + + let mut commands: Vec = MappableCommand::STATIC_COMMAND_LIST.into(); + commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { + MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: cmd.doc.to_owned(), + args: Vec::new(), + } + })); + + let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let mut ctx = Context { + register: None, + count: std::num::NonZeroUsize::new(1), + editor: cx.editor, + callback: None, + on_next_key_callback: None, + jobs: cx.jobs, + }; + command.execute(&mut ctx); + }); + compositor.push(Box::new(overlayed(picker))); + }, + )); +} + +fn last_picker(cx: &mut Context) { + // TODO: last picker does not seem to work well with buffer_picker + cx.callback = Some(Box::new(|compositor, cx| { + if let Some(picker) = compositor.last_picker.take() { + compositor.push(picker); + } else { + cx.editor.set_error("no last picker") + } + })); +} + +// I inserts at the first nonwhitespace character of each line with a selection +fn insert_at_line_start(cx: &mut Context) { + goto_first_nonwhitespace(cx); + enter_insert_mode(cx); +} + +// A inserts at the end of each line with a selection +fn insert_at_line_end(cx: &mut Context) { + enter_insert_mode(cx); + let (view, doc) = current!(cx.editor); + + let selection = doc.selection(view.id).clone().transform(|range| { + let text = doc.text().slice(..); + let line = range.cursor_line(text); + let pos = line_end_char_index(&text, line); + Range::new(pos, pos) + }); + doc.set_selection(view.id, selection); +} + +// Creates an LspCallback that waits for formatting changes to be computed. When they're done, +// it applies them, but only if the doc hasn't changed. +// +// TODO: provide some way to cancel this, probably as part of a more general job cancellation +// scheme +async fn make_format_callback( + doc_id: DocumentId, + doc_version: i32, + view_id: ViewId, + format: impl Future> + Send + 'static, + write: Option<(Option, bool)>, +) -> anyhow::Result { + let format = format.await; + + let call: job::Callback = Callback::Editor(Box::new(move |editor| { + if !editor.documents.contains_key(&doc_id) || !editor.tree.contains(view_id) { + return; + } + + let scrolloff = editor.config().scrolloff; + let doc = doc_mut!(editor, &doc_id); + let view = view_mut!(editor, view_id); + + if let Ok(format) = format { + if doc.version() == doc_version { + apply_transaction(&format, doc, view); + doc.append_changes_to_history(view.id); + doc.detect_indent_and_line_ending(); + view.ensure_cursor_in_view(doc, scrolloff); + } else { + log::info!("discarded formatting changes because the document changed"); + } + } + + if let Some((path, force)) = write { + let id = doc.id(); + if let Err(err) = editor.save(id, path, force) { + editor.set_error(format!("Error saving: {}", err)); + } + } + })); + + Ok(call) +} + +#[derive(PartialEq, Eq)] +pub enum Open { + Below, + Above, +} + +fn open(cx: &mut Context, open: Open) { + let count = cx.count(); + enter_insert_mode(cx); + let (view, doc) = current!(cx.editor); + + let text = doc.text().slice(..); + let contents = doc.text(); + let selection = doc.selection(view.id); + + let mut ranges = SmallVec::with_capacity(selection.len()); + let mut offs = 0; + + let mut transaction = Transaction::change_by_selection(contents, selection, |range| { + let cursor_line = text.char_to_line(match open { + Open::Below => graphemes::prev_grapheme_boundary(text, range.to()), + Open::Above => range.from(), + }); + let new_line = match open { + // adjust position to the end of the line (next line - 1) + Open::Below => cursor_line + 1, + // adjust position to the end of the previous line (current line - 1) + Open::Above => cursor_line, + }; + + // Index to insert newlines after, as well as the char width + // to use to compensate for those inserted newlines. + let (line_end_index, line_end_offset_width) = if new_line == 0 { + (0, 0) + } else { + ( + line_end_char_index(&doc.text().slice(..), new_line.saturating_sub(1)), + doc.line_ending.len_chars(), + ) + }; + + let indent = indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.indent_style, + doc.tab_width(), + text, + new_line.saturating_sub(1), + line_end_index, + cursor_line, + ); + let indent_len = indent.len(); + let mut text = String::with_capacity(1 + indent_len); + text.push_str(doc.line_ending.as_str()); + text.push_str(&indent); + let text = text.repeat(count); + + // calculate new selection ranges + let pos = offs + line_end_index + line_end_offset_width; + for i in 0..count { + // pos -> beginning of reference line, + // + (i * (1+indent_len)) -> beginning of i'th line from pos + // + indent_len -> -> indent for i'th line + ranges.push(Range::point(pos + (i * (1 + indent_len)) + indent_len)); + } + + offs += text.chars().count(); + + (line_end_index, line_end_index, Some(text.into())) + }); + + transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + + apply_transaction(&transaction, doc, view); +} + +// o inserts a new line after each line with a selection +fn open_below(cx: &mut Context) { + open(cx, Open::Below) +} + +// O inserts a new line before each line with a selection +fn open_above(cx: &mut Context) { + open(cx, Open::Above) +} + +fn normal_mode(cx: &mut Context) { + if cx.editor.mode == Mode::Normal { + return; + } + + cx.editor.mode = Mode::Normal; + let (view, doc) = current!(cx.editor); + + try_restore_indent(doc, view); + + // if leaving append mode, move cursor back by 1 + if doc.restore_cursor { + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + Range::new( + range.from(), + graphemes::prev_grapheme_boundary(text, range.to()), + ) + }); + + doc.set_selection(view.id, selection); + doc.restore_cursor = false; + } +} + +fn try_restore_indent(doc: &mut Document, view: &mut View) { + use helix_core::chars::char_is_whitespace; + use helix_core::Operation; + + fn inserted_a_new_blank_line(changes: &[Operation], pos: usize, line_end_pos: usize) -> bool { + if let [Operation::Retain(move_pos), Operation::Insert(ref inserted_str), Operation::Retain(_)] = + changes + { + move_pos + inserted_str.len() == pos + && inserted_str.starts_with('\n') + && inserted_str.chars().skip(1).all(char_is_whitespace) + && pos == line_end_pos // ensure no characters exists after current position + } else { + false + } + } + + let doc_changes = doc.changes().changes(); + let text = doc.text().slice(..); + let range = doc.selection(view.id).primary(); + let pos = range.cursor(text); + let line_end_pos = line_end_char_index(&text, range.cursor_line(text)); + + if inserted_a_new_blank_line(doc_changes, pos, line_end_pos) { + // Removes tailing whitespaces. + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { + let line_start_pos = text.line_to_char(range.cursor_line(text)); + (line_start_pos, pos, None) + }); + apply_transaction(&transaction, doc, view); + } +} + +// Store a jump on the jumplist. +fn push_jump(view: &mut View, doc: &Document) { + let jump = (doc.id(), doc.selection(view.id).clone()); + view.jumps.push(jump); +} + +fn goto_line(cx: &mut Context) { + goto_line_impl(cx.editor, cx.count) +} + +fn goto_line_impl(editor: &mut Editor, count: Option) { + if let Some(count) = count { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + let max_line = if text.line(text.len_lines() - 1).len_chars() == 0 { + // If the last line is blank, don't jump to it. + text.len_lines().saturating_sub(2) + } else { + text.len_lines() - 1 + }; + let line_idx = std::cmp::min(count.get() - 1, max_line); + let pos = text.line_to_char(line_idx); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, editor.mode == Mode::Select)); + + push_jump(view, doc); + doc.set_selection(view.id, selection); + } +} + +fn goto_last_line(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let line_idx = if text.line(text.len_lines() - 1).len_chars() == 0 { + // If the last line is blank, don't jump to it. + text.len_lines().saturating_sub(2) + } else { + text.len_lines() - 1 + }; + let pos = text.line_to_char(line_idx); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); + + push_jump(view, doc); + doc.set_selection(view.id, selection); +} + +fn goto_last_accessed_file(cx: &mut Context) { + let view = view_mut!(cx.editor); + if let Some(alt) = view.docs_access_history.pop() { + cx.editor.switch(alt, Action::Replace); + } else { + cx.editor.set_error("no last accessed buffer") + } +} + +fn goto_last_modification(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let pos = doc.history.get_mut().last_edit_pos(); + let text = doc.text().slice(..); + if let Some(pos) = pos { + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); + doc.set_selection(view.id, selection); + } +} + +fn goto_last_modified_file(cx: &mut Context) { + let view = view!(cx.editor); + let alternate_file = view + .last_modified_docs + .into_iter() + .flatten() + .find(|&id| id != view.doc); + if let Some(alt) = alternate_file { + cx.editor.switch(alt, Action::Replace); + } else { + cx.editor.set_error("no last modified buffer") + } +} + +fn select_mode(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + // Make sure end-of-document selections are also 1-width. + // (With the exception of being in an empty document, of course.) + let selection = doc.selection(view.id).clone().transform(|range| { + if range.is_empty() && range.head == text.len_chars() { + Range::new( + graphemes::prev_grapheme_boundary(text, range.anchor), + range.head, + ) + } else { + range + } + }); + doc.set_selection(view.id, selection); + + cx.editor.mode = Mode::Select; +} + +fn exit_select_mode(cx: &mut Context) { + if cx.editor.mode == Mode::Select { + cx.editor.mode = Mode::Normal; + } +} + +fn goto_pos(editor: &mut Editor, pos: usize) { + let (view, doc) = current!(editor); + + push_jump(view, doc); + doc.set_selection(view.id, Selection::point(pos)); + align_view(doc, view, Align::Center); +} + +fn goto_first_diag(cx: &mut Context) { + let doc = doc!(cx.editor); + let pos = match doc.diagnostics().first() { + Some(diag) => diag.range.start, + None => return, + }; + goto_pos(cx.editor, pos); +} + +fn goto_last_diag(cx: &mut Context) { + let doc = doc!(cx.editor); + let pos = match doc.diagnostics().last() { + Some(diag) => diag.range.start, + None => return, + }; + goto_pos(cx.editor, pos); +} + +fn goto_next_diag(cx: &mut Context) { + let editor = &mut cx.editor; + let (view, doc) = current!(editor); + + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + + let diag = doc + .diagnostics() + .iter() + .find(|diag| diag.range.start > cursor_pos) + .or_else(|| doc.diagnostics().first()); + + let pos = match diag { + Some(diag) => diag.range.start, + None => return, + }; + + goto_pos(editor, pos); +} + +fn goto_prev_diag(cx: &mut Context) { + let editor = &mut cx.editor; + let (view, doc) = current!(editor); + + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + + let diag = doc + .diagnostics() + .iter() + .rev() + .find(|diag| diag.range.start < cursor_pos) + .or_else(|| doc.diagnostics().last()); + + let pos = match diag { + Some(diag) => diag.range.start, + None => return, + }; + + goto_pos(editor, pos); +} + +pub mod insert { + use super::*; + pub type Hook = fn(&Rope, &Selection, char) -> Option; + pub type PostHook = fn(&mut Context, char); + + /// Exclude the cursor in range. + fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { + if range.to() == cursor.to() && text.len_chars() != cursor.to() { + Range::new( + range.from(), + graphemes::prev_grapheme_boundary(text, cursor.to()), + ) + } else { + range + } + } + + // It trigger completion when idle timer reaches deadline + // Only trigger completion if the word under cursor is longer than n characters + pub fn idle_completion(cx: &mut Context) { + let config = cx.editor.config(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + use helix_core::chars::char_is_word; + let mut iter = text.chars_at(cursor); + iter.reverse(); + + for _ in 0..config.completion_trigger_len { + match iter.next() { + Some(c) if char_is_word(c) => {} + Some(c) if config.completion_trigger_chars.contains(&c) => {} + _ => return, + } + } + super::completion(cx); + } + + pub fn is_server_trigger_char(doc: &Document, ch: char) -> bool { + use helix_lsp::lsp; + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return false, + }; + + let capabilities = language_server.capabilities(); + + if let Some(lsp::CompletionOptions { + trigger_characters: Some(triggers), + .. + }) = &capabilities.completion_provider + { + triggers.iter().any(|trigger| trigger.contains(ch)) + } else { + false + } + } + + fn language_server_completion(cx: &mut Context, ch: char) { + use helix_core::chars::char_is_word; + + let config = cx.editor.config(); + if !config.auto_completion { + return; + } + let (view, doc) = current_ref!(cx.editor); + if char_is_word(ch) && doc.savepoint.is_none() { + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + let mut iter = text.chars_at(cursor); + iter.reverse(); + for _ in 0..config.completion_trigger_len { + if iter.next().map_or(true, |c| !char_is_word(c)) { + return; + } + } + cx.editor.reset_idle_timer(); + return; + } + if is_server_trigger_char(doc, ch) { + cx.editor.reset_idle_timer_zero(); + } + } + + fn signature_help(cx: &mut Context, ch: char) { + use helix_lsp::lsp; + // if ch matches signature_help char, trigger + let doc = doc_mut!(cx.editor); + // The language_server!() macro is not used here since it will + // print an "LSP not active for current buffer" message on + // every keypress. + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + + let capabilities = language_server.capabilities(); + + if let lsp::ServerCapabilities { + signature_help_provider: + Some(lsp::SignatureHelpOptions { + trigger_characters: Some(triggers), + // TODO: retrigger_characters + .. + }), + .. + } = capabilities + { + // TODO: what if trigger is multiple chars long + let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch)); + // lsp doesn't tell us when to close the signature help, so we request + // the help information again after common close triggers which should + // return None, which in turn closes the popup. + let close_triggers = &[')', ';', '.']; + + if is_trigger || close_triggers.contains(&ch) { + super::signature_help_impl(cx, SignatureHelpInvoked::Automatic); + } + } + } + + // The default insert hook: simply insert the character + #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature + fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option { + let cursors = selection.clone().cursors(doc.slice(..)); + let mut t = Tendril::new(); + t.push(ch); + let transaction = Transaction::insert(doc, &cursors, t); + Some(transaction) + } + + use helix_core::auto_pairs; + + pub fn insert_char(cx: &mut Context, c: char) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text(); + let selection = doc.selection(view.id); + let auto_pairs = doc.auto_pairs(cx.editor); + + let transaction = auto_pairs + .as_ref() + .and_then(|ap| auto_pairs::hook(text, selection, c, ap)) + .or_else(|| insert(text, selection, c)); + + let (view, doc) = current!(cx.editor); + if let Some(t) = transaction { + apply_transaction(&t, doc, view); + } + + // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) + // this could also generically look at Transaction, but it's a bit annoying to look at + // Operation instead of Change. + for hook in &[language_server_completion, signature_help] { + hook(cx, c); + } + } + + pub fn insert_tab(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + // TODO: round out to nearest indentation level (for example a line with 3 spaces should + // indent by one to reach 4 spaces). + + let indent = Tendril::from(doc.indent_style.as_str()); + let transaction = Transaction::insert( + doc.text(), + &doc.selection(view.id).clone().cursors(doc.text().slice(..)), + indent, + ); + apply_transaction(&transaction, doc, view); + } + + pub fn insert_newline(cx: &mut Context) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + + let contents = doc.text(); + let selection = doc.selection(view.id).clone(); + let mut ranges = SmallVec::with_capacity(selection.len()); + + // TODO: this is annoying, but we need to do it to properly calculate pos after edits + let mut global_offs = 0; + + let mut transaction = Transaction::change_by_selection(contents, &selection, |range| { + let pos = range.cursor(text); + + let prev = if pos == 0 { + ' ' + } else { + contents.char(pos - 1) + }; + let curr = contents.get_char(pos).unwrap_or(' '); + + let current_line = text.char_to_line(pos); + let line_is_only_whitespace = text + .line(current_line) + .chars() + .all(|char| char.is_ascii_whitespace()); + + let mut new_text = String::new(); + + // If the current line is all whitespace, insert a line ending at the beginning of + // the current line. This makes the current line empty and the new line contain the + // indentation of the old line. + let (from, to, local_offs) = if line_is_only_whitespace { + let line_start = text.line_to_char(current_line); + new_text.push_str(doc.line_ending.as_str()); + + (line_start, line_start, new_text.chars().count()) + } else { + let indent = indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.indent_style, + doc.tab_width(), + text, + current_line, + pos, + current_line, + ); + + // If we are between pairs (such as brackets), we want to + // insert an additional line which is indented one level + // more and place the cursor there + let on_auto_pair = doc + .auto_pairs(cx.editor) + .and_then(|pairs| pairs.get(prev)) + .and_then(|pair| if pair.close == curr { Some(pair) } else { None }) + .is_some(); + + let local_offs = if on_auto_pair { + let inner_indent = indent.clone() + doc.indent_style.as_str(); + new_text.reserve_exact(2 + indent.len() + inner_indent.len()); + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&inner_indent); + let local_offs = new_text.chars().count(); + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&indent); + local_offs + } else { + new_text.reserve_exact(1 + indent.len()); + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&indent); + new_text.chars().count() + }; + + (pos, pos, local_offs) + }; + + let new_range = if doc.restore_cursor { + // when appending, extend the range by local_offs + Range::new( + range.anchor + global_offs, + range.head + local_offs + global_offs, + ) + } else { + // when inserting, slide the range by local_offs + Range::new( + range.anchor + local_offs + global_offs, + range.head + local_offs + global_offs, + ) + }; + + // TODO: range replace or extend + // range.replace(|range| range.is_empty(), head); -> fn extend if cond true, new head pos + // can be used with cx.mode to do replace or extend on most changes + ranges.push(new_range); + global_offs += new_text.chars().count(); + + (from, to, Some(new_text.into())) + }); + + transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + + let (view, doc) = current!(cx.editor); + apply_transaction(&transaction, doc, view); + } + + pub fn delete_char_backward(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + let indent_unit = doc.indent_style.as_str(); + let tab_size = doc.tab_width(); + let auto_pairs = doc.auto_pairs(cx.editor); + + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { + let pos = range.cursor(text); + if pos == 0 { + return (pos, pos, None); + } + let line_start_pos = text.line_to_char(range.cursor_line(text)); + // consider to delete by indent level if all characters before `pos` are indent units. + let fragment = Cow::from(text.slice(line_start_pos..pos)); + if !fragment.is_empty() && fragment.chars().all(|ch| ch == ' ' || ch == '\t') { + if text.get_char(pos.saturating_sub(1)) == Some('\t') { + // fast path, delete one char + ( + graphemes::nth_prev_grapheme_boundary(text, pos, 1), + pos, + None, + ) + } else { + let unit_len = indent_unit.chars().count(); + // NOTE: indent_unit always contains 'only spaces' or 'only tab' according to `IndentStyle` definition. + let unit_size = if indent_unit.starts_with('\t') { + tab_size * unit_len + } else { + unit_len + }; + let width: usize = fragment + .chars() + .map(|ch| { + if ch == '\t' { + tab_size + } else { + // it can be none if it still meet control characters other than '\t' + // here just set the width to 1 (or some value better?). + ch.width().unwrap_or(1) + } + }) + .sum(); + let mut drop = width % unit_size; // round down to nearest unit + if drop == 0 { + drop = unit_size + }; // if it's already at a unit, consume a whole unit + let mut chars = fragment.chars().rev(); + let mut start = pos; + for _ in 0..drop { + // delete up to `drop` spaces + match chars.next() { + Some(' ') => start -= 1, + _ => break, + } + } + (start, pos, None) // delete! + } + } else { + match ( + text.get_char(pos.saturating_sub(1)), + text.get_char(pos), + auto_pairs, + ) { + (Some(_x), Some(_y), Some(ap)) + if range.is_single_grapheme(text) + && ap.get(_x).is_some() + && ap.get(_x).unwrap().open == _x + && ap.get(_x).unwrap().close == _y => + // delete both autopaired characters + { + ( + graphemes::nth_prev_grapheme_boundary(text, pos, count), + graphemes::nth_next_grapheme_boundary(text, pos, count), + None, + ) + } + _ => + // delete 1 char + { + ( + graphemes::nth_prev_grapheme_boundary(text, pos, count), + pos, + None, + ) + } + } + } + }); + let (view, doc) = current!(cx.editor); + apply_transaction(&transaction, doc, view); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); + } + + pub fn delete_char_forward(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { + let pos = range.cursor(text); + ( + pos, + graphemes::nth_next_grapheme_boundary(text, pos, count), + None, + ) + }); + apply_transaction(&transaction, doc, view); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); + } + + pub fn delete_word_backward(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let anchor = movement::move_prev_word_start(text, range, count).from(); + let next = Range::new(anchor, range.cursor(text)); + exclude_cursor(text, next, range) + }); + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); + } + + pub fn delete_word_forward(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let head = movement::move_next_word_end(text, range, count).to(); + Range::new(range.cursor(text), head) + }); + + delete_selection_insert_mode(doc, view, &selection); + + lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); + } +} + +// Undo / Redo + +fn undo(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + for _ in 0..count { + if !doc.undo(view.id) { + cx.editor.set_status("Already at oldest change"); + break; + } + } +} + +fn redo(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + for _ in 0..count { + if !doc.redo(view.id) { + cx.editor.set_status("Already at newest change"); + break; + } + } +} + +fn earlier(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + for _ in 0..count { + // rather than doing in batch we do this so get error halfway + if !doc.earlier(view.id, UndoKind::Steps(1)) { + cx.editor.set_status("Already at oldest change"); + break; + } + } +} + +fn later(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + for _ in 0..count { + // rather than doing in batch we do this so get error halfway + if !doc.later(view.id, UndoKind::Steps(1)) { + cx.editor.set_status("Already at newest change"); + break; + } + } +} + +fn commit_undo_checkpoint(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + doc.append_changes_to_history(view.id); +} + +// Yank / Paste + +fn yank(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let values: Vec = doc + .selection(view.id) + .fragments(text) + .map(Cow::into_owned) + .collect(); + + let msg = format!( + "yanked {} selection(s) to register {}", + values.len(), + cx.register.unwrap_or('"') + ); + + cx.editor + .registers + .write(cx.register.unwrap_or('"'), values); + + cx.editor.set_status(msg); + exit_select_mode(cx); +} + +fn yank_joined_to_clipboard_impl( + editor: &mut Editor, + separator: &str, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + + let values: Vec = doc + .selection(view.id) + .fragments(text) + .map(Cow::into_owned) + .collect(); + + let clipboard_text = match clipboard_type { + ClipboardType::Clipboard => "system clipboard", + ClipboardType::Selection => "primary clipboard", + }; + + let msg = format!( + "joined and yanked {} selection(s) to {}", + values.len(), + clipboard_text, + ); + + let joined = values.join(separator); + + editor + .clipboard_provider + .set_contents(joined, clipboard_type) + .context("Couldn't set system clipboard content")?; + + editor.set_status(msg); + + Ok(()) +} + +fn yank_joined_to_clipboard(cx: &mut Context) { + let line_ending = doc!(cx.editor).line_ending; + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Clipboard); + exit_select_mode(cx); +} + +fn yank_main_selection_to_clipboard_impl( + editor: &mut Editor, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + + let message_text = match clipboard_type { + ClipboardType::Clipboard => "yanked main selection to system clipboard", + ClipboardType::Selection => "yanked main selection to primary clipboard", + }; + + let value = doc.selection(view.id).primary().fragment(text); + + if let Err(e) = editor + .clipboard_provider + .set_contents(value.into_owned(), clipboard_type) + { + bail!("Couldn't set system clipboard content: {}", e); + } + + editor.set_status(message_text); + Ok(()) +} + +fn yank_main_selection_to_clipboard(cx: &mut Context) { + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard); +} + +fn yank_joined_to_primary_clipboard(cx: &mut Context) { + let line_ending = doc!(cx.editor).line_ending; + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Selection); +} + +fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection); + exit_select_mode(cx); +} + +#[derive(Copy, Clone)] +enum Paste { + Before, + After, + Cursor, +} + +fn paste_impl( + values: &[String], + doc: &mut Document, + view: &mut View, + action: Paste, + count: usize, + mode: Mode, +) { + if values.is_empty() { + return; + } + + let repeat = std::iter::repeat( + // `values` is asserted to have at least one entry above. + values + .last() + .map(|value| Tendril::from(value.repeat(count))) + .unwrap(), + ); + + // if any of values ends with a line ending, it's linewise paste + let linewise = values + .iter() + .any(|value| get_line_ending_of_str(value).is_some()); + + // Only compiled once. + static REGEX: Lazy = Lazy::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); + let mut values = values + .iter() + .map(|value| REGEX.replace_all(value, doc.line_ending.as_str())) + .map(|value| Tendril::from(value.as_ref().repeat(count))) + .chain(repeat); + + let text = doc.text(); + let selection = doc.selection(view.id); + + let mut offset = 0; + let mut ranges = SmallVec::with_capacity(selection.len()); + + let mut transaction = Transaction::change_by_selection(text, selection, |range| { + let pos = match (action, linewise) { + // paste linewise before + (Paste::Before, true) => text.line_to_char(text.char_to_line(range.from())), + // paste linewise after + (Paste::After, true) => { + let line = range.line_range(text.slice(..)).1; + text.line_to_char((line + 1).min(text.len_lines())) + } + // paste insert + (Paste::Before, false) => range.from(), + // paste append + (Paste::After, false) => range.to(), + // paste at cursor + (Paste::Cursor, _) => range.cursor(text.slice(..)), + }; + + let value = values.next(); + + let value_len = value + .as_ref() + .map(|content| content.chars().count()) + .unwrap_or_default(); + let anchor = offset + pos; + + let new_range = Range::new(anchor, anchor + value_len).with_direction(range.direction()); + ranges.push(new_range); + offset += value_len; + + (pos, pos, value) + }); + + if mode == Mode::Normal { + transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + } + + apply_transaction(&transaction, doc, view); +} + +pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { + let count = cx.count(); + let paste = match cx.editor.mode { + Mode::Insert | Mode::Select => Paste::Cursor, + Mode::Normal => Paste::Before, + }; + let (view, doc) = current!(cx.editor); + paste_impl(&[contents], doc, view, paste, count, cx.editor.mode); +} + +fn paste_clipboard_impl( + editor: &mut Editor, + action: Paste, + clipboard_type: ClipboardType, + count: usize, +) -> anyhow::Result<()> { + let (view, doc) = current!(editor); + match editor.clipboard_provider.get_contents(clipboard_type) { + Ok(contents) => { + paste_impl(&[contents], doc, view, action, count, editor.mode); + Ok(()) + } + Err(e) => Err(e.context("Couldn't get system clipboard contents")), + } +} + +fn paste_clipboard_after(cx: &mut Context) { + let _ = paste_clipboard_impl( + cx.editor, + Paste::After, + ClipboardType::Clipboard, + cx.count(), + ); +} + +fn paste_clipboard_before(cx: &mut Context) { + let _ = paste_clipboard_impl( + cx.editor, + Paste::Before, + ClipboardType::Clipboard, + cx.count(), + ); +} + +fn paste_primary_clipboard_after(cx: &mut Context) { + let _ = paste_clipboard_impl( + cx.editor, + Paste::After, + ClipboardType::Selection, + cx.count(), + ); +} + +fn paste_primary_clipboard_before(cx: &mut Context) { + let _ = paste_clipboard_impl( + cx.editor, + Paste::Before, + ClipboardType::Selection, + cx.count(), + ); +} + +fn replace_with_yanked(cx: &mut Context) { + let count = cx.count(); + let reg_name = cx.register.unwrap_or('"'); + let (view, doc) = current!(cx.editor); + let registers = &mut cx.editor.registers; + + if let Some(values) = registers.read(reg_name) { + if !values.is_empty() { + let repeat = std::iter::repeat( + values + .last() + .map(|value| Tendril::from(&value.repeat(count))) + .unwrap(), + ); + let mut values = values + .iter() + .map(|value| Tendril::from(&value.repeat(count))) + .chain(repeat); + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + if !range.is_empty() { + (range.from(), range.to(), Some(values.next().unwrap())) + } else { + (range.from(), range.to(), None) + } + }); + + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); + } + } +} + +fn replace_selections_with_clipboard_impl( + cx: &mut Context, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + + match cx.editor.clipboard_provider.get_contents(clipboard_type) { + Ok(contents) => { + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + ( + range.from(), + range.to(), + Some(contents.repeat(count).as_str().into()), + ) + }); + + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view.id); + } + Err(e) => return Err(e.context("Couldn't get system clipboard contents")), + } + + exit_select_mode(cx); + Ok(()) +} + +fn replace_selections_with_clipboard(cx: &mut Context) { + let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard); +} + +fn replace_selections_with_primary_clipboard(cx: &mut Context) { + let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Selection); +} + +fn paste(cx: &mut Context, pos: Paste) { + let count = cx.count(); + let reg_name = cx.register.unwrap_or('"'); + let (view, doc) = current!(cx.editor); + let registers = &mut cx.editor.registers; + + if let Some(values) = registers.read(reg_name) { + paste_impl(values, doc, view, pos, count, cx.editor.mode); + } +} + +fn paste_after(cx: &mut Context) { + paste(cx, Paste::After) +} + +fn paste_before(cx: &mut Context) { + paste(cx, Paste::Before) +} + +fn get_lines(doc: &Document, view_id: ViewId) -> Vec { + let mut lines = Vec::new(); + + // Get all line numbers + for range in doc.selection(view_id) { + let (start, end) = range.line_range(doc.text().slice(..)); + + for line in start..=end { + lines.push(line) + } + } + lines.sort_unstable(); // sorting by usize so _unstable is preferred + lines.dedup(); + lines +} + +fn indent(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let lines = get_lines(doc, view.id); + + // Indent by one level + let indent = Tendril::from(doc.indent_style.as_str().repeat(count)); + + let transaction = Transaction::change( + doc.text(), + lines.into_iter().filter_map(|line| { + let is_blank = doc.text().line(line).chunks().all(|s| s.trim().is_empty()); + if is_blank { + return None; + } + let pos = doc.text().line_to_char(line); + Some((pos, pos, Some(indent.clone()))) + }), + ); + apply_transaction(&transaction, doc, view); +} + +fn unindent(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let lines = get_lines(doc, view.id); + let mut changes = Vec::with_capacity(lines.len()); + let tab_width = doc.tab_width(); + let indent_width = count * tab_width; + + for line_idx in lines { + let line = doc.text().line(line_idx); + let mut width = 0; + let mut pos = 0; + + for ch in line.chars() { + match ch { + ' ' => width += 1, + '\t' => width = (width / tab_width + 1) * tab_width, + _ => break, + } + + pos += 1; + + if width >= indent_width { + break; + } + } + + // now delete from start to first non-blank + if pos > 0 { + let start = doc.text().line_to_char(line_idx); + changes.push((start, start + pos, None)) + } + } + + let transaction = Transaction::change(doc.text(), changes.into_iter()); + + apply_transaction(&transaction, doc, view); +} + +fn format_selections(cx: &mut Context) { + use helix_lsp::{lsp, util::range_to_lsp_range}; + + let (view, doc) = current!(cx.editor); + + // via lsp if available + // TODO: else via tree-sitter indentation calculations + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + + let ranges: Vec = doc + .selection(view.id) + .iter() + .map(|range| range_to_lsp_range(doc.text(), *range, language_server.offset_encoding())) + .collect(); + + if ranges.len() != 1 { + cx.editor + .set_error("format_selections only supports a single selection for now"); + return; + } + + // TODO: handle fails + // TODO: concurrent map over all ranges + + let range = ranges[0]; + + let request = match language_server.text_document_range_formatting( + doc.identifier(), + range, + lsp::FormattingOptions::default(), + None, + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support range formatting"); + return; + } + }; + + let edits = tokio::task::block_in_place(|| helix_lsp::block_on(request)).unwrap_or_default(); + + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + edits, + language_server.offset_encoding(), + ); + + apply_transaction(&transaction, doc, view); +} + +fn join_selections_impl(cx: &mut Context, select_space: bool) { + use movement::skip_while; + let (view, doc) = current!(cx.editor); + let text = doc.text(); + let slice = doc.text().slice(..); + + let mut changes = Vec::new(); + let fragment = Tendril::from(" "); + + for selection in doc.selection(view.id) { + let (start, mut end) = selection.line_range(slice); + if start == end { + end = (end + 1).min(text.len_lines() - 1); + } + let lines = start..end; + + changes.reserve(lines.len()); + + for line in lines { + let start = line_end_char_index(&slice, line); + let mut end = text.line_to_char(line + 1); + end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end); + + // need to skip from start, not end + let change = (start, end, Some(fragment.clone())); + changes.push(change); + } + } + + changes.sort_unstable_by_key(|(from, _to, _text)| *from); + changes.dedup(); + + // TODO: joining multiple empty lines should be replaced by a single space. + // need to merge change ranges that touch + + // select inserted spaces + let transaction = if select_space { + let ranges: SmallVec<_> = changes + .iter() + .scan(0, |offset, change| { + let range = Range::point(change.0 - *offset); + *offset += change.1 - change.0 - 1; // -1 because cursor is 0-sized + Some(range) + }) + .collect(); + let selection = Selection::new(ranges, 0); + Transaction::change(doc.text(), changes.into_iter()).with_selection(selection) + } else { + Transaction::change(doc.text(), changes.into_iter()) + }; + + apply_transaction(&transaction, doc, view); +} + +fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { + // keep or remove selections matching regex + let reg = cx.register.unwrap_or('/'); + ui::regex_prompt( + cx, + if remove { "remove:" } else { "keep:" }.into(), + Some(reg), + ui::completers::none, + move |editor, regex, event| { + let (view, doc) = current!(editor); + if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { + return; + } + let text = doc.text().slice(..); + + if let Some(selection) = + selection::keep_or_remove_matches(text, doc.selection(view.id), ®ex, remove) + { + doc.set_selection(view.id, selection); + } + }, + ) +} + +fn join_selections(cx: &mut Context) { + join_selections_impl(cx, false) +} + +fn join_selections_space(cx: &mut Context) { + join_selections_impl(cx, true) +} + +fn keep_selections(cx: &mut Context) { + keep_or_remove_selections_impl(cx, false) +} + +fn remove_selections(cx: &mut Context) { + keep_or_remove_selections_impl(cx, true) +} + +fn keep_primary_selection(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + // TODO: handle count + + let range = doc.selection(view.id).primary(); + doc.set_selection(view.id, Selection::single(range.anchor, range.head)); +} + +fn remove_primary_selection(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + // TODO: handle count + + let selection = doc.selection(view.id); + if selection.len() == 1 { + cx.editor.set_error("no selections remaining"); + return; + } + let index = selection.primary_index(); + let selection = selection.clone().remove(index); + + doc.set_selection(view.id, selection); +} + +pub fn completion(cx: &mut Context) { + use helix_lsp::{lsp, util::pos_to_lsp_pos}; + + let (view, doc) = current!(cx.editor); + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + + let offset_encoding = language_server.offset_encoding(); + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + let pos = pos_to_lsp_pos(doc.text(), cursor, offset_encoding); + +<<<<<<< HEAD + let future = language_server.completion(doc.identifier(), pos, None); + let future = async move { + match future.await { + Ok(v) => Ok(v), + Err(helix_lsp::Error::Timeout) => Ok(serde_json::Value::Null), + Err(e) => Err(e), + } + }; +||||||| 4ec2a21c + let future = language_server.completion(doc.identifier(), pos, None); +======= + let future = match language_server.completion(doc.identifier(), pos, None) { + Some(future) => future, + None => return, + }; +>>>>>>> master + + let trigger_offset = cursor; + + // TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply + // completion filtering. For example logger.te| should filter the initial suggestion list with "te". + + use helix_core::chars; + let mut iter = text.chars_at(cursor); + iter.reverse(); + let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count(); + let start_offset = cursor.saturating_sub(offset); + + doc.savepoint(); + let trigger_version = doc.version(); + cx.callback( + future, + move |editor, compositor, response: Option| { + let doc = doc_mut!(editor); + let savepoint = match doc.savepoint.take() { + Some(s) => s, + None => return, + }; + if editor.mode != Mode::Insert { + return; + } + if savepoint.0 != trigger_version { + doc.savepoint = Some(savepoint); + return; + } + + let items = match response { + Some(lsp::CompletionResponse::Array(items)) => items, + // TODO: do something with is_incomplete + Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: _is_incomplete, + items, + })) => items, + None => { + editor.set_status( + "The completion response is none and will request server again", + ); + editor.reset_idle_timer(); + return; + } + }; + + if items.is_empty() { + // editor.set_error("No completion available".to_string()); + return; + } + doc.savepoint = Some(savepoint); + let size = compositor.size(); + let ui = compositor.find::().unwrap(); + ui.set_completion( + editor, + items, + offset_encoding, + start_offset, + trigger_offset, + size, + ); + }, + ); +} + +// comments +fn toggle_comments(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let token = doc + .language_config() + .and_then(|lc| lc.comment_token.as_ref()) + .map(|tc| tc.as_ref()); + let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); + + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); +} + +fn rotate_selections(cx: &mut Context, direction: Direction) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let mut selection = doc.selection(view.id).clone(); + let index = selection.primary_index(); + let len = selection.len(); + selection.set_primary_index(match direction { + Direction::Forward => (index + count) % len, + Direction::Backward => (index + (len.saturating_sub(count) % len)) % len, + }); + doc.set_selection(view.id, selection); +} +fn rotate_selections_forward(cx: &mut Context) { + rotate_selections(cx, Direction::Forward) +} +fn rotate_selections_backward(cx: &mut Context) { + rotate_selections(cx, Direction::Backward) +} + +fn rotate_selection_contents(cx: &mut Context, direction: Direction) { + let count = cx.count; + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id); + let mut fragments: Vec<_> = selection + .slices(text) + .map(|fragment| fragment.chunks().collect()) + .collect(); + + let group = count + .map(|count| count.get()) + .unwrap_or(fragments.len()) // default to rotating everything as one group + .min(fragments.len()); + + for chunk in fragments.chunks_mut(group) { + // TODO: also modify main index + match direction { + Direction::Forward => chunk.rotate_right(1), + Direction::Backward => chunk.rotate_left(1), + }; + } + + let transaction = Transaction::change( + doc.text(), + selection + .ranges() + .iter() + .zip(fragments) + .map(|(range, fragment)| (range.from(), range.to(), Some(fragment))), + ); + + apply_transaction(&transaction, doc, view); +} + +fn rotate_selection_contents_forward(cx: &mut Context) { + rotate_selection_contents(cx, Direction::Forward) +} +fn rotate_selection_contents_backward(cx: &mut Context) { + rotate_selection_contents(cx, Direction::Backward) +} + +// tree sitter node selection + +fn expand_selection(cx: &mut Context) { + let motion = |editor: &mut Editor| { + let (view, doc) = current!(editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + + let current_selection = doc.selection(view.id); + let selection = object::expand_selection(syntax, text, current_selection.clone()); + + // check if selection is different from the last one + if *current_selection != selection { + // save current selection so it can be restored using shrink_selection + view.object_selections.push(current_selection.clone()); + + doc.set_selection(view.id, selection); + } + } + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn shrink_selection(cx: &mut Context) { + let motion = |editor: &mut Editor| { + let (view, doc) = current!(editor); + let current_selection = doc.selection(view.id); + // try to restore previous selection + if let Some(prev_selection) = view.object_selections.pop() { + if current_selection.contains(&prev_selection) { + // allow shrinking the selection only if current selection contains the previous object selection + doc.set_selection(view.id, prev_selection); + return; + } else { + // clear existing selection as they can't be shrunk to anyway + view.object_selections.clear(); + } + } + // if not previous selection, shrink to first child + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let selection = object::shrink_selection(syntax, text, current_selection.clone()); + doc.set_selection(view.id, selection); + } + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn select_sibling_impl(cx: &mut Context, sibling_fn: &'static F) +where + F: Fn(Node) -> Option, +{ + let motion = |editor: &mut Editor| { + let (view, doc) = current!(editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let current_selection = doc.selection(view.id); + let selection = + object::select_sibling(syntax, text, current_selection.clone(), sibling_fn); + doc.set_selection(view.id, selection); + } + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn select_next_sibling(cx: &mut Context) { + select_sibling_impl(cx, &|node| Node::next_sibling(&node)) +} + +fn select_prev_sibling(cx: &mut Context) { + select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) +} + +fn match_brackets(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let selection = doc.selection(view.id).clone().transform(|range| { + if let Some(pos) = + match_brackets::find_matching_bracket_fuzzy(syntax, doc.text(), range.cursor(text)) + { + range.put_cursor(text, pos, cx.editor.mode == Mode::Select) + } else { + range + } + }); + doc.set_selection(view.id, selection); + } +} + +// + +fn jump_forward(cx: &mut Context) { + let count = cx.count(); + let view = view_mut!(cx.editor); + let doc_id = view.doc; + + if let Some((id, selection)) = view.jumps.forward(count) { + view.doc = *id; + let selection = selection.clone(); + let (view, doc) = current!(cx.editor); // refetch doc + + if doc.id() != doc_id { + view.add_to_history(doc_id); + } + + doc.set_selection(view.id, selection); + align_view(doc, view, Align::Center); + }; +} + +fn jump_backward(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let doc_id = doc.id(); + + if let Some((id, selection)) = view.jumps.backward(view.id, doc, count) { + view.doc = *id; + let selection = selection.clone(); + let (view, doc) = current!(cx.editor); // refetch doc + + if doc.id() != doc_id { + view.add_to_history(doc_id); + } + + doc.set_selection(view.id, selection); + align_view(doc, view, Align::Center); + }; +} + +fn save_selection(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + push_jump(view, doc); + cx.editor.set_status("Selection saved to jumplist"); +} + +fn rotate_view(cx: &mut Context) { + cx.editor.focus_next() +} + +fn jump_view_right(cx: &mut Context) { + cx.editor.focus_direction(tree::Direction::Right) +} + +fn jump_view_left(cx: &mut Context) { + cx.editor.focus_direction(tree::Direction::Left) +} + +fn jump_view_up(cx: &mut Context) { + cx.editor.focus_direction(tree::Direction::Up) +} + +fn jump_view_down(cx: &mut Context) { + cx.editor.focus_direction(tree::Direction::Down) +} + +fn swap_view_right(cx: &mut Context) { + cx.editor.swap_split_in_direction(tree::Direction::Right) +} + +fn swap_view_left(cx: &mut Context) { + cx.editor.swap_split_in_direction(tree::Direction::Left) +} + +fn swap_view_up(cx: &mut Context) { + cx.editor.swap_split_in_direction(tree::Direction::Up) +} + +fn swap_view_down(cx: &mut Context) { + cx.editor.swap_split_in_direction(tree::Direction::Down) +} + +fn transpose_view(cx: &mut Context) { + cx.editor.transpose_view() +} + +// split helper, clear it later +fn split(cx: &mut Context, action: Action) { + let (view, doc) = current!(cx.editor); + let id = doc.id(); + let selection = doc.selection(view.id).clone(); + let offset = view.offset; + + cx.editor.switch(id, action); + + // match the selection in the previous view + let (view, doc) = current!(cx.editor); + doc.set_selection(view.id, selection); + // match the view scroll offset (switch doesn't handle this fully + // since the selection is only matched after the split) + view.offset = offset; +} + +fn hsplit(cx: &mut Context) { + split(cx, Action::HorizontalSplit); +} + +fn hsplit_new(cx: &mut Context) { + cx.editor.new_file(Action::HorizontalSplit); +} + +fn vsplit(cx: &mut Context) { + split(cx, Action::VerticalSplit); +} + +fn vsplit_new(cx: &mut Context) { + cx.editor.new_file(Action::VerticalSplit); +} + +fn wclose(cx: &mut Context) { + if cx.editor.tree.views().count() == 1 { + if let Err(err) = typed::buffers_remaining_impl(cx.editor) { + cx.editor.set_error(err.to_string()); + return; + } + } + let view_id = view!(cx.editor).id; + // close current split + cx.editor.close(view_id); +} + +fn wonly(cx: &mut Context) { + let views = cx + .editor + .tree + .views() + .map(|(v, focus)| (v.id, focus)) + .collect::>(); + for (view_id, focus) in views { + if !focus { + cx.editor.close(view_id); + } + } +} + +fn select_register(cx: &mut Context) { + cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers)); + cx.on_next_key(move |cx, event| { + if let Some(ch) = event.char() { + cx.editor.autoinfo = None; + cx.editor.selected_register = Some(ch); + } + }) +} + +fn insert_register(cx: &mut Context) { + cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers)); + cx.on_next_key(move |cx, event| { + if let Some(ch) = event.char() { + cx.editor.autoinfo = None; + cx.register = Some(ch); + paste(cx, Paste::Cursor); + } + }) +} + +fn align_view_top(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + align_view(doc, view, Align::Top); +} + +fn align_view_center(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + align_view(doc, view, Align::Center); +} + +fn align_view_bottom(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + align_view(doc, view, Align::Bottom); +} + +fn align_view_middle(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let pos = doc.selection(view.id).primary().cursor(text); + let pos = coords_at_pos(text, pos); + + view.offset.col = pos + .col + .saturating_sub((view.inner_area(doc).width as usize) / 2); +} + +fn scroll_up(cx: &mut Context) { + scroll(cx, cx.count(), Direction::Backward); +} + +fn scroll_down(cx: &mut Context) { + scroll(cx, cx.count(), Direction::Forward); +} + +fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direction) { + let count = cx.count(); + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); + if let Some((lang_config, syntax)) = doc.language_config().zip(doc.syntax()) { + let text = doc.text().slice(..); + let root = syntax.tree().root_node(); + + let selection = doc.selection(view.id).clone().transform(|range| { + let new_range = movement::goto_treesitter_object( + text, + range, + object, + direction, + root, + lang_config, + count, + ); + + if editor.mode == Mode::Select { + let head = if new_range.head < range.anchor { + new_range.anchor + } else { + new_range.head + }; + + Range::new(range.anchor, head) + } else { + new_range.with_direction(direction) + } + }); + + doc.set_selection(view.id, selection); + } else { + editor.set_status("Syntax-tree is not available in current buffer"); + } + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn goto_next_function(cx: &mut Context) { + goto_ts_object_impl(cx, "function", Direction::Forward) +} + +fn goto_prev_function(cx: &mut Context) { + goto_ts_object_impl(cx, "function", Direction::Backward) +} + +fn goto_next_class(cx: &mut Context) { + goto_ts_object_impl(cx, "class", Direction::Forward) +} + +fn goto_prev_class(cx: &mut Context) { + goto_ts_object_impl(cx, "class", Direction::Backward) +} + +fn goto_next_parameter(cx: &mut Context) { + goto_ts_object_impl(cx, "parameter", Direction::Forward) +} + +fn goto_prev_parameter(cx: &mut Context) { + goto_ts_object_impl(cx, "parameter", Direction::Backward) +} + +fn goto_next_comment(cx: &mut Context) { + goto_ts_object_impl(cx, "comment", Direction::Forward) +} + +fn goto_prev_comment(cx: &mut Context) { + goto_ts_object_impl(cx, "comment", Direction::Backward) +} + +fn goto_next_test(cx: &mut Context) { + goto_ts_object_impl(cx, "test", Direction::Forward) +} + +fn goto_prev_test(cx: &mut Context) { + goto_ts_object_impl(cx, "test", Direction::Backward) +} + +fn select_textobject_around(cx: &mut Context) { + select_textobject(cx, textobject::TextObject::Around); +} + +fn select_textobject_inner(cx: &mut Context) { + select_textobject(cx, textobject::TextObject::Inside); +} + +fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { + let count = cx.count(); + + cx.on_next_key(move |cx, event| { + cx.editor.autoinfo = None; + if let Some(ch) = event.char() { + let textobject = move |editor: &mut Editor| { + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + + let textobject_treesitter = |obj_name: &str, range: Range| -> Range { + let (lang_config, syntax) = match doc.language_config().zip(doc.syntax()) { + Some(t) => t, + None => return range, + }; + textobject::textobject_treesitter( + text, + range, + objtype, + obj_name, + syntax.tree().root_node(), + lang_config, + count, + ) + }; + + let selection = doc.selection(view.id).clone().transform(|range| { + match ch { + 'w' => textobject::textobject_word(text, range, objtype, count, false), + 'W' => textobject::textobject_word(text, range, objtype, count, true), + 'c' => textobject_treesitter("class", range), + 'f' => textobject_treesitter("function", range), + 'a' => textobject_treesitter("parameter", range), + 'o' => textobject_treesitter("comment", range), + 't' => textobject_treesitter("test", range), + 'p' => textobject::textobject_paragraph(text, range, objtype, count), + 'm' => textobject::textobject_pair_surround_closest( + text, range, objtype, count, + ), + // TODO: cancel new ranges if inconsistent surround matches across lines + ch if !ch.is_ascii_alphanumeric() => { + textobject::textobject_pair_surround(text, range, objtype, ch, count) + } + _ => range, + } + }); + doc.set_selection(view.id, selection); + }; + textobject(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(textobject))); + } + }); + + let title = match objtype { + textobject::TextObject::Inside => "Match inside", + textobject::TextObject::Around => "Match around", + _ => return, + }; + let help_text = [ + ("w", "Word"), + ("W", "WORD"), + ("p", "Paragraph"), + ("c", "Class (tree-sitter)"), + ("f", "Function (tree-sitter)"), + ("a", "Argument/parameter (tree-sitter)"), + ("o", "Comment (tree-sitter)"), + ("t", "Test (tree-sitter)"), + ("m", "Closest surrounding pair to cursor"), + (" ", "... or any character acting as a pair"), + ]; + + cx.editor.autoinfo = Some(Info::new(title, &help_text)); +} + +fn surround_add(cx: &mut Context) { + cx.on_next_key(move |cx, event| { + let ch = match event.char() { + Some(ch) => ch, + None => return, + }; + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let (open, close) = surround::get_pair(ch); + // The number of chars in get_pair + let surround_len = 2; + + let mut changes = Vec::with_capacity(selection.len() * 2); + let mut ranges = SmallVec::with_capacity(selection.len()); + let mut offs = 0; + + for range in selection.iter() { + let mut o = Tendril::new(); + o.push(open); + let mut c = Tendril::new(); + c.push(close); + changes.push((range.from(), range.from(), Some(o))); + changes.push((range.to(), range.to(), Some(c))); + + // Add 2 characters to the range to select them + ranges.push( + Range::new(offs + range.from(), offs + range.to() + surround_len) + .with_direction(range.direction()), + ); + + // Add 2 characters to the offset for the next ranges + offs += surround_len; + } + + let transaction = Transaction::change(doc.text(), changes.into_iter()) + .with_selection(Selection::new(ranges, selection.primary_index())); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); + }) +} + +fn surround_replace(cx: &mut Context) { + let count = cx.count(); + cx.on_next_key(move |cx, event| { + let surround_ch = match event.char() { + Some('m') => None, // m selects the closest surround pair + Some(ch) => Some(ch), + None => return, + }; + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + + let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + + cx.on_next_key(move |cx, event| { + let (view, doc) = current!(cx.editor); + let to = match event.char() { + Some(to) => to, + None => return, + }; + let (open, close) = surround::get_pair(to); + let transaction = Transaction::change( + doc.text(), + change_pos.iter().enumerate().map(|(i, &pos)| { + let mut t = Tendril::new(); + t.push(if i % 2 == 0 { open } else { close }); + (pos, pos + 1, Some(t)) + }), + ); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); + }); + }) +} + +fn surround_delete(cx: &mut Context) { + let count = cx.count(); + cx.on_next_key(move |cx, event| { + let surround_ch = match event.char() { + Some('m') => None, // m selects the closest surround pair + Some(ch) => Some(ch), + None => return, + }; + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + + let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + + let transaction = + Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); + }) +} + +#[derive(Eq, PartialEq)] +enum ShellBehavior { + Replace, + Ignore, + Insert, + Append, +} + +fn shell_pipe(cx: &mut Context) { + shell_prompt(cx, "pipe:".into(), ShellBehavior::Replace); +} + +fn shell_pipe_to(cx: &mut Context) { + shell_prompt(cx, "pipe-to:".into(), ShellBehavior::Ignore); +} + +fn shell_insert_output(cx: &mut Context) { + shell_prompt(cx, "insert-output:".into(), ShellBehavior::Insert); +} + +fn shell_append_output(cx: &mut Context) { + shell_prompt(cx, "append-output:".into(), ShellBehavior::Append); +} + +fn shell_keep_pipe(cx: &mut Context) { + ui::prompt( + cx, + "keep-pipe:".into(), + Some('|'), + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + let shell = &cx.editor.config().shell; + if event != PromptEvent::Validate { + return; + } + if input.is_empty() { + return; + } + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + + let mut ranges = SmallVec::with_capacity(selection.len()); + let old_index = selection.primary_index(); + let mut index: Option = None; + let text = doc.text().slice(..); + + for (i, range) in selection.ranges().iter().enumerate() { + let fragment = range.slice(text); + let (_output, success) = match shell_impl(shell, input, Some(fragment.into())) { + Ok(result) => result, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + + // if the process exits successfully, keep the selection + if success { + ranges.push(*range); + if i >= old_index && index.is_none() { + index = Some(ranges.len() - 1); + } + } + } + + if ranges.is_empty() { + cx.editor.set_error("No selections remaining"); + return; + } + + let index = index.unwrap_or_else(|| ranges.len() - 1); + doc.set_selection(view.id, Selection::new(ranges, index)); + }, + ); +} + +fn shell_impl(shell: &[String], cmd: &str, input: Option) -> anyhow::Result<(Tendril, bool)> { + tokio::task::block_in_place(|| helix_lsp::block_on(shell_impl_async(shell, cmd, input))) +} + +async fn shell_impl_async( + shell: &[String], + cmd: &str, + input: Option, +) -> anyhow::Result<(Tendril, bool)> { + use std::process::Stdio; + use tokio::process::Command; + ensure!(!shell.is_empty(), "No shell set"); + + let mut process = Command::new(&shell[0]); + process + .args(&shell[1..]) + .arg(cmd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if input.is_some() || cfg!(windows) { + process.stdin(Stdio::piped()); + } else { + process.stdin(Stdio::null()); + } + + let mut process = match process.spawn() { + Ok(process) => process, + Err(e) => { + log::error!("Failed to start shell: {}", e); + return Err(e.into()); + } + }; + let output = if let Some(mut stdin) = process.stdin.take() { + let input_task = tokio::spawn(async move { + if let Some(input) = input { + helix_view::document::to_writer(&mut stdin, encoding::UTF_8, &input).await?; + } + Ok::<_, anyhow::Error>(()) + }); + let (output, _) = tokio::join! { + process.wait_with_output(), + input_task, + }; + output? + } else { + // Process has no stdin, so we just take the output + process.wait_with_output().await? + }; + + if !output.status.success() { + if !output.stderr.is_empty() { + let err = String::from_utf8_lossy(&output.stderr).to_string(); + log::error!("Shell error: {}", err); + bail!("Shell error: {}", err); + } + bail!("Shell command failed"); + } else if !output.stderr.is_empty() { + log::debug!( + "Command printed to stderr: {}", + String::from_utf8_lossy(&output.stderr).to_string() + ); + } + + let str = std::str::from_utf8(&output.stdout) + .map_err(|_| anyhow!("Process did not output valid UTF-8"))?; + let tendril = Tendril::from(str); + Ok((tendril, output.status.success())) +} + +fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { + let pipe = match behavior { + ShellBehavior::Replace | ShellBehavior::Ignore => true, + ShellBehavior::Insert | ShellBehavior::Append => false, + }; + + let config = cx.editor.config(); + let shell = &config.shell; + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + + let mut changes = Vec::with_capacity(selection.len()); + let mut ranges = SmallVec::with_capacity(selection.len()); + let text = doc.text().slice(..); + + let mut shell_output: Option = None; + let mut offset = 0isize; + for range in selection.ranges() { + let (output, success) = if let Some(output) = shell_output.as_ref() { + (output.clone(), true) + } else { + let fragment = range.slice(text); + match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { + Ok(result) => { + if !pipe { + shell_output = Some(result.0.clone()); + } + result + } + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + } + }; + + if !success { + cx.editor.set_error("Command failed"); + return; + } + + let output_len = output.chars().count(); + + let (from, to, deleted_len) = match behavior { + ShellBehavior::Replace => (range.from(), range.to(), range.len()), + ShellBehavior::Insert => (range.from(), range.from(), 0), + ShellBehavior::Append => (range.to(), range.to(), 0), + _ => (range.from(), range.from(), 0), + }; + + // These `usize`s cannot underflow because selection ranges cannot overlap. + // Once the MSRV is 1.66.0 (mixed_integer_ops is stabilized), we can use checked + // arithmetic to assert this. + let anchor = (to as isize + offset - deleted_len as isize) as usize; + let new_range = Range::new(anchor, anchor + output_len).with_direction(range.direction()); + ranges.push(new_range); + offset = offset + output_len as isize - deleted_len as isize; + + changes.push((from, to, Some(output))); + } + + if behavior != &ShellBehavior::Ignore { + let transaction = Transaction::change(doc.text(), changes.into_iter()) + .with_selection(Selection::new(ranges, selection.primary_index())); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view.id); + } + + // after replace cursor may be out of bounds, do this to + // make sure cursor is in view and update scroll as well + view.ensure_cursor_in_view(doc, config.scrolloff); +} + +fn shell_prompt(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { + ui::prompt( + cx, + prompt, + Some('|'), + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + if input.is_empty() { + return; + } + + shell(cx, input, &behavior); + }, + ); +} + +fn suspend(_cx: &mut Context) { + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap(); +} + +fn add_newline_above(cx: &mut Context) { + add_newline_impl(cx, Open::Above); +} + +fn add_newline_below(cx: &mut Context) { + add_newline_impl(cx, Open::Below) +} + +fn add_newline_impl(cx: &mut Context, open: Open) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let text = doc.text(); + let slice = text.slice(..); + + let changes = selection.into_iter().map(|range| { + let (start, end) = range.line_range(slice); + let line = match open { + Open::Above => start, + Open::Below => end + 1, + }; + let pos = text.line_to_char(line); + ( + pos, + pos, + Some(doc.line_ending.as_str().repeat(count).into()), + ) + }); + + let transaction = Transaction::change(text, changes); + apply_transaction(&transaction, doc, view); +} + +enum IncrementDirection { + Increase, + Decrease, +} +/// Increment object under cursor by count. +fn increment(cx: &mut Context) { + increment_impl(cx, IncrementDirection::Increase); +} + +/// Decrement object under cursor by count. +fn decrement(cx: &mut Context) { + increment_impl(cx, IncrementDirection::Decrease); +} + +/// This function differs from find_next_char_impl in that it stops searching at the newline, but also +/// starts searching at the current character, instead of the next. +/// It does not want to start at the next character because this function is used for incrementing +/// number and we don't want to move forward if we're already on a digit. +fn find_next_char_until_newline( + text: RopeSlice, + char_matcher: M, + pos: usize, + _count: usize, + _inclusive: bool, +) -> Option { + // Since we send the current line to find_nth_next instead of the whole text, we need to adjust + // the position we send to this function so that it's relative to that line and its returned + // position since it's expected this function returns a global position. + let line_index = text.char_to_line(pos); + let pos_delta = text.line_to_char(line_index); + let pos = pos - pos_delta; + search::find_nth_next(text.line(line_index), char_matcher, pos, 1).map(|pos| pos + pos_delta) +} + +/// Decrement object under cursor by `amount`. +fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { + // TODO: when incrementing or decrementing a number that gets a new digit or lose one, the + // selection is updated improperly. + find_char_impl( + cx.editor, + &find_next_char_until_newline, + true, + true, + char::is_ascii_digit, + 1, + ); + + // Increase by 1 if `IncrementDirection` is `Increase` + // Decrease by 1 if `IncrementDirection` is `Decrease` + let sign = match increment_direction { + IncrementDirection::Increase => 1, + IncrementDirection::Decrease => -1, + }; + let mut amount = sign * cx.count() as i64; + + // If the register is `#` then increase or decrease the `amount` by 1 per element + let increase_by = if cx.register == Some('#') { sign } else { 0 }; + + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let text = doc.text().slice(..); + + let changes: Vec<_> = selection + .ranges() + .iter() + .filter_map(|range| { + let incrementor: Box = + if let Some(incrementor) = DateTimeIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else if let Some(incrementor) = NumberIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else { + return None; + }; + + let (range, new_text) = incrementor.increment(amount); + + amount += increase_by; + + Some((range.from(), range.to(), Some(new_text))) + }) + .collect(); + + // Overlapping changes in a transaction will panic, so we need to find and remove them. + // For example, if there are cursors on each of the year, month, and day of `2021-11-29`, + // incrementing will give overlapping changes, with each change incrementing a different part of + // the date. Since these conflict with each other we remove these changes from the transaction + // so nothing happens. + let mut overlapping_indexes = HashSet::new(); + for (i, changes) in changes.windows(2).enumerate() { + if changes[0].1 > changes[1].0 { + overlapping_indexes.insert(i); + overlapping_indexes.insert(i + 1); + } + } + let changes: Vec<_> = changes + .into_iter() + .enumerate() + .filter_map(|(i, change)| { + if overlapping_indexes.contains(&i) { + None + } else { + Some(change) + } + }) + .collect(); + + if !changes.is_empty() { + let transaction = Transaction::change(doc.text(), changes.into_iter()); + let transaction = transaction.with_selection(selection.clone()); + + apply_transaction(&transaction, doc, view); + } +} + +fn record_macro(cx: &mut Context) { + if let Some((reg, mut keys)) = cx.editor.macro_recording.take() { + // Remove the keypress which ends the recording + keys.pop(); + let s = keys + .into_iter() + .map(|key| { + let s = key.to_string(); + if s.chars().count() == 1 { + s + } else { + format!("<{}>", s) + } + }) + .collect::(); + cx.editor.registers.write(reg, vec![s]); + cx.editor + .set_status(format!("Recorded to register [{}]", reg)); + } else { + let reg = cx.register.take().unwrap_or('@'); + cx.editor.macro_recording = Some((reg, Vec::new())); + cx.editor + .set_status(format!("Recording to register [{}]", reg)); + } +} + +fn replay_macro(cx: &mut Context) { + let reg = cx.register.unwrap_or('@'); + + if cx.editor.macro_replaying.contains(®) { + cx.editor.set_error(format!( + "Cannot replay from register [{}] because already replaying from same register", + reg + )); + return; + } + + let keys: Vec = if let Some([keys_str]) = cx.editor.registers.read(reg) { + match helix_view::input::parse_macro(keys_str) { + Ok(keys) => keys, + Err(err) => { + cx.editor.set_error(format!("Invalid macro: {}", err)); + return; + } + } + } else { + cx.editor.set_error(format!("Register [{}] empty", reg)); + return; + }; + + // Once the macro has been fully validated, it's marked as being under replay + // to ensure we don't fall into infinite recursion. + cx.editor.macro_replaying.push(reg); + + let count = cx.count(); + cx.callback = Some(Box::new(move |compositor, cx| { + for _ in 0..count { + for &key in keys.iter() { + compositor.handle_event(&compositor::Event::Key(key), cx); + } + } + // The macro under replay is cleared at the end of the callback, not in the + // macro replay context, or it will not correctly protect the user from + // replaying recursively. + cx.editor.macro_replaying.pop(); + })); +} diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index c27417e3..b182f28c 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -85,7 +85,7 @@ fn thread_picker( frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1), )); - Some((path, pos)) + Some((path.into(), pos)) }, ); compositor.push(Box::new(picker)); @@ -706,7 +706,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { .and_then(|source| source.path.clone()) .map(|path| { ( - path, + path.into(), Some(( frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1), diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index aa9dd69f..627bf207 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -156,7 +156,7 @@ fn location_to_file_location(location: &lsp::Location) -> FileLocation { location.range.start.line as usize, location.range.end.line as usize, )); - (path, line) + (path.into(), line) } // TODO: share with symbol picker(symbol.location) @@ -333,7 +333,14 @@ pub fn symbol_picker(cx: &mut Context) { let current_url = doc.url(); let offset_encoding = language_server.offset_encoding(); - let future = language_server.document_symbols(doc.identifier()); + let future = match language_server.document_symbols(doc.identifier()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support document symbols"); + return; + } + }; cx.callback( future, @@ -365,7 +372,14 @@ pub fn workspace_symbol_picker(cx: &mut Context) { let current_url = doc.url(); let language_server = language_server!(cx.editor, doc); let offset_encoding = language_server.offset_encoding(); - let future = language_server.workspace_symbols("".to_string()); + let future = match language_server.workspace_symbols("".to_string()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support workspace symbols"); + return; + } + }; cx.callback( future, @@ -493,7 +507,7 @@ pub fn code_action(cx: &mut Context) { let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - let future = language_server.code_actions( + let future = match language_server.code_actions( doc.identifier(), range, // Filter and convert overlapping diagnostics @@ -509,7 +523,14 @@ pub fn code_action(cx: &mut Context) { .collect(), only: None, }, - ); + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support code actions"); + return; + } + }; cx.callback( future, @@ -567,35 +588,34 @@ pub fn code_action(cx: &mut Context) { .reverse() }); - let mut picker = - ui::Menu::new(actions, false, (), move |editor, code_action, event| { - if event != PromptEvent::Validate { - return; - } + let mut picker = ui::Menu::new(actions, true, (), move |editor, code_action, event| { + if event != PromptEvent::Validate { + return; + } - // always present here - let code_action = code_action.unwrap(); + // always present here + let code_action = code_action.unwrap(); - match code_action { - lsp::CodeActionOrCommand::Command(command) => { - log::debug!("code action command: {:?}", command); - execute_lsp_command(editor, command.clone()); + match code_action { + lsp::CodeActionOrCommand::Command(command) => { + log::debug!("code action command: {:?}", command); + execute_lsp_command(editor, command.clone()); + } + lsp::CodeActionOrCommand::CodeAction(code_action) => { + log::debug!("code action: {:?}", code_action); + if let Some(ref workspace_edit) = code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + apply_workspace_edit(editor, offset_encoding, workspace_edit); } - lsp::CodeActionOrCommand::CodeAction(code_action) => { - log::debug!("code action: {:?}", code_action); - if let Some(ref workspace_edit) = code_action.edit { - log::debug!("edit: {:?}", workspace_edit); - apply_workspace_edit(editor, offset_encoding, workspace_edit); - } - // if code action provides both edit and command first the edit - // should be applied and then the command - if let Some(command) = &code_action.command { - execute_lsp_command(editor, command.clone()); - } + // if code action provides both edit and command first the edit + // should be applied and then the command + if let Some(command) = &code_action.command { + execute_lsp_command(editor, command.clone()); } } - }); + } + }); picker.move_down(); // pre-select the first item let popup = Popup::new("code-action", picker); @@ -645,9 +665,16 @@ pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { // the command is executed on the server and communicated back // to the client asynchronously using workspace edits - let command_future = language_server.command(cmd); + let future = match language_server.command(cmd) { + Some(future) => future, + None => { + editor.set_error("Language server does not support executing commands"); + return; + } + }; + tokio::spawn(async move { - let res = command_future.await; + let res = future.await; if let Err(e) = res { log::error!("execute LSP command: {}", e); @@ -881,7 +908,14 @@ pub fn goto_definition(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let future = language_server.goto_definition(doc.identifier(), pos, None); + let future = match language_server.goto_definition(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-definition"); + return; + } + }; cx.callback( future, @@ -899,7 +933,14 @@ pub fn goto_type_definition(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let future = language_server.goto_type_definition(doc.identifier(), pos, None); + let future = match language_server.goto_type_definition(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-type-definition"); + return; + } + }; cx.callback( future, @@ -917,7 +958,14 @@ pub fn goto_implementation(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let future = language_server.goto_implementation(doc.identifier(), pos, None); + let future = match language_server.goto_implementation(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-implementation"); + return; + } + }; cx.callback( future, @@ -935,7 +983,14 @@ pub fn goto_reference(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let future = language_server.goto_reference(doc.identifier(), pos, None); + let future = match language_server.goto_reference(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-reference"); + return; + } + }; cx.callback( future, @@ -978,7 +1033,13 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) { Some(f) => f, - None => return, + None => { + if was_manually_invoked { + cx.editor + .set_error("Language server does not support signature-help"); + } + return; + } }; cx.callback( @@ -1079,7 +1140,14 @@ pub fn hover(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let future = language_server.text_document_hover(doc.identifier(), pos, None); + let future = match language_server.text_document_hover(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support hover"); + return; + } + }; cx.callback( future, @@ -1149,8 +1217,16 @@ pub fn rename_symbol(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); - match block_on(task) { + let future = + match language_server.rename_symbol(doc.identifier(), pos, input.to_string()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support symbol renaming"); + return; + } + }; + match block_on(future) { Ok(edits) => apply_workspace_edit(cx.editor, offset_encoding, &edits), Err(err) => cx.editor.set_error(err.to_string()), } @@ -1165,7 +1241,15 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { let pos = doc.position(view.id, offset_encoding); - let future = language_server.text_document_document_highlight(doc.identifier(), pos, None); + let future = match language_server.text_document_document_highlight(doc.identifier(), pos, None) + { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support document highlight"); + return; + } + }; cx.callback( future, diff --git a/helix-term/src/commands/lsp.rs.orig b/helix-term/src/commands/lsp.rs.orig new file mode 100644 index 00000000..cb178ea2 --- /dev/null +++ b/helix-term/src/commands/lsp.rs.orig @@ -0,0 +1,1297 @@ +use helix_lsp::{ + block_on, + lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString}, + util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, + OffsetEncoding, +}; +use tui::text::{Span, Spans}; + +use super::{align_view, push_jump, Align, Context, Editor, Open}; + +use helix_core::{path, Selection}; +use helix_view::{apply_transaction, document::Mode, editor::Action, theme::Style}; + +use crate::{ + compositor::{self, Compositor}, + ui::{ + self, lsp::SignatureHelp, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent, + }, +}; + +use std::{ + borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc, +}; + +/// Gets the language server that is attached to a document, and +/// if it's not active displays a status message. Using this macro +/// in a context where the editor automatically queries the LSP +/// (instead of when the user explicitly does so via a keybind like +/// `gd`) will spam the "LSP inactive" status message confusingly. +#[macro_export] +macro_rules! language_server { + ($editor:expr, $doc:expr) => { + match $doc.language_server() { + Some(language_server) => language_server, + None => { + $editor.set_status("Language server not active for current buffer"); + return; + } + } + }; +} + +impl ui::menu::Item for lsp::Location { + /// Current working directory. + type Data = PathBuf; + + fn label(&self, cwdir: &Self::Data) -> Spans { + // The preallocation here will overallocate a few characters since it will account for the + // URL's scheme, which is not used most of the time since that scheme will be "file://". + // Those extra chars will be used to avoid allocating when writing the line number (in the + // common case where it has 5 digits or less, which should be enough for a cast majority + // of usages). + let mut res = String::with_capacity(self.uri.as_str().len()); + + if self.uri.scheme() == "file" { + // With the preallocation above and UTF-8 paths already, this closure will do one (1) + // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. + let mut write_path_to_res = || -> Option<()> { + let path = self.uri.to_file_path().ok()?; + res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy()); + Some(()) + }; + write_path_to_res(); + } else { + // Never allocates since we declared the string with this capacity already. + res.push_str(self.uri.as_str()); + } + + // Most commonly, this will not allocate, especially on Unix systems where the root prefix + // is a simple `/` and not `C:\` (with whatever drive letter) + write!(&mut res, ":{}", self.range.start.line) + .expect("Will only failed if allocating fail"); + res.into() + } +} + +impl ui::menu::Item for lsp::SymbolInformation { + /// Path to currently focussed document + type Data = Option; + + fn label(&self, current_doc_path: &Self::Data) -> Spans { + if current_doc_path.as_ref() == Some(&self.location.uri) { + self.name.as_str().into() + } else { + match self.location.uri.to_file_path() { + Ok(path) => { + let get_relative_path = path::get_relative_path(path.as_path()); + format!("{} ({})", &self.name, get_relative_path.to_string_lossy()).into() + } + Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(), + } + } + } +} + +struct DiagnosticStyles { + hint: Style, + info: Style, + warning: Style, + error: Style, +} + +struct PickerDiagnostic { + url: lsp::Url, + diag: lsp::Diagnostic, +} + +impl ui::menu::Item for PickerDiagnostic { + type Data = (DiagnosticStyles, DiagnosticsFormat); + + fn label(&self, (styles, format): &Self::Data) -> Spans { + let mut style = self + .diag + .severity + .map(|s| match s { + DiagnosticSeverity::HINT => styles.hint, + DiagnosticSeverity::INFORMATION => styles.info, + DiagnosticSeverity::WARNING => styles.warning, + DiagnosticSeverity::ERROR => styles.error, + _ => Style::default(), + }) + .unwrap_or_default(); + + // remove background as it is distracting in the picker list + style.bg = None; + + let code: Cow<'_, str> = self + .diag + .code + .as_ref() + .map(|c| match c { + NumberOrString::Number(n) => n.to_string().into(), + NumberOrString::String(s) => s.as_str().into(), + }) + .unwrap_or_default(); + + let path = match format { + DiagnosticsFormat::HideSourcePath => String::new(), + DiagnosticsFormat::ShowSourcePath => { + let path = path::get_truncated_path(self.url.path()); + format!("{}: ", path.to_string_lossy()) + } + }; + + Spans::from(vec![ + Span::raw(path), + Span::styled(&self.diag.message, style), + Span::styled(code, style), + ]) + } +} + +fn location_to_file_location(location: &lsp::Location) -> FileLocation { + let path = location.uri.to_file_path().unwrap(); + let line = Some(( + location.range.start.line as usize, + location.range.end.line as usize, + )); + (path.into(), line) +} + +// TODO: share with symbol picker(symbol.location) +fn jump_to_location( + editor: &mut Editor, + location: &lsp::Location, + offset_encoding: OffsetEncoding, + action: Action, +) { + let (view, doc) = current!(editor); + push_jump(view, doc); + + let path = match location.uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", location.uri); + editor.set_error(err); + return; + } + }; + match editor.open(&path, action) { + Ok(_) => (), + Err(err) => { + let err = format!("failed to open path: {:?}: {:?}", location.uri, err); + editor.set_error(err); + return; + } + } + let (view, doc) = current!(editor); + let definition_pos = location.range.start; + // TODO: convert inside server + let new_pos = if let Some(new_pos) = lsp_pos_to_pos(doc.text(), definition_pos, offset_encoding) + { + new_pos + } else { + return; + }; + doc.set_selection(view.id, Selection::point(new_pos)); + align_view(doc, view, Align::Center); +} + +fn sym_picker( + symbols: Vec, + current_path: Option, + offset_encoding: OffsetEncoding, +) -> FilePicker { + // TODO: drop current_path comparison and instead use workspace: bool flag? + FilePicker::new( + symbols, + current_path.clone(), + move |cx, symbol, action| { + let (view, doc) = current!(cx.editor); + push_jump(view, doc); + + if current_path.as_ref() != Some(&symbol.location.uri) { + let uri = &symbol.location.uri; + let path = match uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", uri); + cx.editor.set_error(err); + return; + } + }; + if let Err(err) = cx.editor.open(&path, action) { + let err = format!("failed to open document: {}: {}", uri, err); + log::error!("{}", err); + cx.editor.set_error(err); + return; + } + } + + let (view, doc) = current!(cx.editor); + + if let Some(range) = + lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) + { + // we flip the range so that the cursor sits on the start of the symbol + // (for example start of the function). + doc.set_selection(view.id, Selection::single(range.head, range.anchor)); + align_view(doc, view, Align::Center); + } + }, + move |_editor, symbol| Some(location_to_file_location(&symbol.location)), + ) + .truncate_start(false) +} + +#[derive(Copy, Clone, PartialEq)] +enum DiagnosticsFormat { + ShowSourcePath, + HideSourcePath, +} + +fn diag_picker( + cx: &Context, + diagnostics: BTreeMap>, + current_path: Option, + format: DiagnosticsFormat, + offset_encoding: OffsetEncoding, +) -> FilePicker { + // TODO: drop current_path comparison and instead use workspace: bool flag? + + // flatten the map to a vec of (url, diag) pairs + let mut flat_diag = Vec::new(); + for (url, diags) in diagnostics { + flat_diag.reserve(diags.len()); + for diag in diags { + flat_diag.push(PickerDiagnostic { + url: url.clone(), + diag, + }); + } + } + + let styles = DiagnosticStyles { + hint: cx.editor.theme.get("hint"), + info: cx.editor.theme.get("info"), + warning: cx.editor.theme.get("warning"), + error: cx.editor.theme.get("error"), + }; + + FilePicker::new( + flat_diag, + (styles, format), + move |cx, PickerDiagnostic { url, diag }, action| { + if current_path.as_ref() == Some(url) { + let (view, doc) = current!(cx.editor); + push_jump(view, doc); + } else { + let path = url.to_file_path().unwrap(); + cx.editor.open(&path, action).expect("editor.open failed"); + } + + let (view, doc) = current!(cx.editor); + + if let Some(range) = lsp_range_to_range(doc.text(), diag.range, offset_encoding) { + // we flip the range so that the cursor sits on the start of the symbol + // (for example start of the function). + doc.set_selection(view.id, Selection::single(range.head, range.anchor)); + align_view(doc, view, Align::Center); + } + }, + move |_editor, PickerDiagnostic { url, diag }| { + let location = lsp::Location::new(url.clone(), diag.range); + Some(location_to_file_location(&location)) + }, + ) + .truncate_start(false) +} + +pub fn symbol_picker(cx: &mut Context) { + fn nested_to_flat( + list: &mut Vec, + file: &lsp::TextDocumentIdentifier, + symbol: lsp::DocumentSymbol, + ) { + #[allow(deprecated)] + list.push(lsp::SymbolInformation { + name: symbol.name, + kind: symbol.kind, + tags: symbol.tags, + deprecated: symbol.deprecated, + location: lsp::Location::new(file.uri.clone(), symbol.selection_range), + container_name: None, + }); + for child in symbol.children.into_iter().flatten() { + nested_to_flat(list, file, child); + } + } + let doc = doc!(cx.editor); + + let language_server = language_server!(cx.editor, doc); + let current_url = doc.url(); + let offset_encoding = language_server.offset_encoding(); + + let future = match language_server.document_symbols(doc.identifier()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support document symbols"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + if let Some(symbols) = response { + // lsp has two ways to represent symbols (flat/nested) + // convert the nested variant to flat, so that we have a homogeneous list + let symbols = match symbols { + lsp::DocumentSymbolResponse::Flat(symbols) => symbols, + lsp::DocumentSymbolResponse::Nested(symbols) => { + let doc = doc!(editor); + let mut flat_symbols = Vec::new(); + for symbol in symbols { + nested_to_flat(&mut flat_symbols, &doc.identifier(), symbol) + } + flat_symbols + } + }; + + let picker = sym_picker(symbols, current_url, offset_encoding); + compositor.push(Box::new(overlayed(picker))) + } + }, + ) +} + +pub fn workspace_symbol_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + let current_url = doc.url(); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + let future = match language_server.workspace_symbols("".to_string()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support workspace symbols"); + return; + } + }; + + cx.callback( + future, + move |_editor, compositor, response: Option>| { + if let Some(symbols) = response { + let picker = sym_picker(symbols, current_url, offset_encoding); + compositor.push(Box::new(overlayed(picker))) + } + }, + ) +} + +pub fn diagnostics_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + let language_server = language_server!(cx.editor, doc); + if let Some(current_url) = doc.url() { + let offset_encoding = language_server.offset_encoding(); + let diagnostics = cx + .editor + .diagnostics + .get(¤t_url) + .cloned() + .unwrap_or_default(); + let picker = diag_picker( + cx, + [(current_url.clone(), diagnostics)].into(), + Some(current_url), + DiagnosticsFormat::HideSourcePath, + offset_encoding, + ); + cx.push_layer(Box::new(overlayed(picker))); + } +} + +pub fn workspace_diagnostics_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let current_url = doc.url(); + let offset_encoding = language_server.offset_encoding(); + let diagnostics = cx.editor.diagnostics.clone(); + let picker = diag_picker( + cx, + diagnostics, + current_url, + DiagnosticsFormat::ShowSourcePath, + offset_encoding, + ); + cx.push_layer(Box::new(overlayed(picker))); +} + +impl ui::menu::Item for lsp::CodeActionOrCommand { + type Data = (); + fn label(&self, _data: &Self::Data) -> Spans { + match self { + lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), + lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), + } + } +} + +/// Determines the category of the `CodeAction` using the `CodeAction::kind` field. +/// Returns a number that represent these categories. +/// Categories with a lower number should be displayed first. +/// +/// +/// While the `kind` field is defined as open ended in the LSP spec (any value may be used) +/// in practice a closed set of common values (mostly suggested in the LSP spec) are used. +/// VSCode displays each of these categories seperatly (seperated by a heading in the codeactions picker) +/// to make them easier to navigate. Helix does not display these headings to the user. +/// However it does sort code actions by their categories to achieve the same order as the VScode picker, +/// just without the headings. +/// +/// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>) +fn action_category(action: &CodeActionOrCommand) -> u32 { + if let CodeActionOrCommand::CodeAction(CodeAction { + kind: Some(kind), .. + }) = action + { + let mut components = kind.as_str().split('.'); + match components.next() { + Some("quickfix") => 0, + Some("refactor") => match components.next() { + Some("extract") => 1, + Some("inline") => 2, + Some("rewrite") => 3, + Some("move") => 4, + Some("surround") => 5, + _ => 7, + }, + Some("source") => 6, + _ => 7, + } + } else { + 7 + } +} + +fn action_prefered(action: &CodeActionOrCommand) -> bool { + matches!( + action, + CodeActionOrCommand::CodeAction(CodeAction { + is_preferred: Some(true), + .. + }) + ) +} + +fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool { + matches!( + action, + CodeActionOrCommand::CodeAction(CodeAction { + diagnostics: Some(diagnostics), + .. + }) if !diagnostics.is_empty() + ) +} + +pub fn code_action(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let language_server = language_server!(cx.editor, doc); + + let selection_range = doc.selection(view.id).primary(); + let offset_encoding = language_server.offset_encoding(); + + let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); + + let future = match language_server.code_actions( + doc.identifier(), + range, + // Filter and convert overlapping diagnostics + lsp::CodeActionContext { + diagnostics: doc + .diagnostics() + .iter() + .filter(|&diag| { + selection_range + .overlaps(&helix_core::Range::new(diag.range.start, diag.range.end)) + }) + .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) + .collect(), + only: None, + }, + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support code actions"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + let mut actions = match response { + Some(a) => a, + None => return, + }; + + // remove disabled code actions + actions.retain(|action| { + matches!( + action, + CodeActionOrCommand::Command(_) + | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) + ) + }); + + if actions.is_empty() { + editor.set_status("No code actions available"); + return; + } + + // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec. + // VScode sorts the codeaction two times: + // + // First the codeactions that fix some diagnostics are moved to the front. + // If both codeactions fix some diagnostics (or both fix none) the codeaction + // that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate + // submenus that only contain a certain category (see `action_category`) of actions. + // + // Below this done in in a single sorting step + actions.sort_by(|action1, action2| { + // sort actions by category + let order = action_category(action1).cmp(&action_category(action2)); + if order != Ordering::Equal { + return order; + } + // within the categories sort by relevancy. + // Modeled after the `codeActionsComparator` function in vscode: + // https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts + + // if one code action fixes a diagnostic but the other one doesn't show it first + let order = action_fixes_diagnostics(action1) + .cmp(&action_fixes_diagnostics(action2)) + .reverse(); + if order != Ordering::Equal { + return order; + } + + // if one of the codeactions is marked as prefered show it first + // otherwise keep the original LSP sorting + action_prefered(action1) + .cmp(&action_prefered(action2)) + .reverse() + }); + + let mut picker = + ui::Menu::new(actions, false, (), move |editor, code_action, event| { + if event != PromptEvent::Validate { + return; + } + + // always present here + let code_action = code_action.unwrap(); + + match code_action { + lsp::CodeActionOrCommand::Command(command) => { + log::debug!("code action command: {:?}", command); + execute_lsp_command(editor, command.clone()); + } + lsp::CodeActionOrCommand::CodeAction(code_action) => { + log::debug!("code action: {:?}", code_action); + if let Some(ref workspace_edit) = code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + apply_workspace_edit(editor, offset_encoding, workspace_edit); + } + + // if code action provides both edit and command first the edit + // should be applied and then the command + if let Some(command) = &code_action.command { + execute_lsp_command(editor, command.clone()); + } + } + } + }); + picker.move_down(); // pre-select the first item + + let popup = Popup::new("code-action", picker).with_scrollbar(false); + compositor.replace_or_push("code-action", popup); + }, + ) +} +<<<<<<< HEAD + +impl ui::menu::Item for lsp::Command { + type Data = (); + fn label(&self, _data: &Self::Data) -> Spans { + self.title.as_str().into() + } +} + +pub fn workspace_command_picker(cx: &mut Context) { + let (_, doc) = current!(cx.editor); + + let language_server = language_server!(cx.editor, doc); + + let execute_command_provider = match &language_server.capabilities().execute_command_provider { + Some(p) => p, + None => return, + }; + let commands = execute_command_provider + .commands + .iter() + .map(|command| lsp::Command { + title: command.clone(), + command: command.clone(), + arguments: None, + }) + .collect::>(); + cx.callback = Some(Box::new( + move |compositor: &mut Compositor, _cx: &mut compositor::Context| { + let picker = ui::Picker::new(commands, (), move |cx, command, _action| { + execute_lsp_command(cx.editor, command.clone()); + }); + compositor.push(Box::new(overlayed(picker))) + }, + )); +} + +||||||| 4ec2a21c +======= + +impl ui::menu::Item for lsp::Command { + type Data = (); + fn label(&self, _data: &Self::Data) -> Spans { + self.title.as_str().into() + } +} + +>>>>>>> master +pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { + let doc = doc!(editor); + let language_server = language_server!(editor, doc); + + // the command is executed on the server and communicated back + // to the client asynchronously using workspace edits + let future = match language_server.command(cmd) { + Some(future) => future, + None => { + editor.set_error("Language server does not support executing commands"); + return; + } + }; + + tokio::spawn(async move { + let res = future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); +} + +pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { + use lsp::ResourceOp; + use std::fs; + match op { + ResourceOp::Create(op) => { + let path = op.uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if ignore_if_exists && path.exists() { + Ok(()) + } else { + // Create directory if it does not exist + if let Some(dir) = path.parent() { + if !dir.is_dir() { + fs::create_dir_all(dir)?; + } + } + + fs::write(&path, []) + } + } + ResourceOp::Delete(op) => { + let path = op.uri.to_file_path().unwrap(); + if path.is_dir() { + let recursive = op + .options + .as_ref() + .and_then(|options| options.recursive) + .unwrap_or(false); + + if recursive { + fs::remove_dir_all(&path) + } else { + fs::remove_dir(&path) + } + } else if path.is_file() { + fs::remove_file(&path) + } else { + Ok(()) + } + } + ResourceOp::Rename(op) => { + let from = op.old_uri.to_file_path().unwrap(); + let to = op.new_uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if ignore_if_exists && to.exists() { + Ok(()) + } else { + fs::rename(&from, &to) + } + } + } +} + +pub fn apply_workspace_edit( + editor: &mut Editor, + offset_encoding: OffsetEncoding, + workspace_edit: &lsp::WorkspaceEdit, +) { + let mut apply_edits = |uri: &helix_lsp::Url, text_edits: Vec| { + let path = match uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", uri); + log::error!("{}", err); + editor.set_error(err); + return; + } + }; + + let current_view_id = view!(editor).id; + let doc_id = match editor.open(&path, Action::Load) { + Ok(doc_id) => doc_id, + Err(err) => { + let err = format!("failed to open document: {}: {}", uri, err); + log::error!("{}", err); + editor.set_error(err); + return; + } + }; + + let doc = doc_mut!(editor, &doc_id); + + // Need to determine a view for apply/append_changes_to_history + let selections = doc.selections(); + let view_id = if selections.contains_key(¤t_view_id) { + // use current if possible + current_view_id + } else { + // Hack: we take the first available view_id + selections + .keys() + .next() + .copied() + .expect("No view_id available") + }; + + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + text_edits, + offset_encoding, + ); + apply_transaction(&transaction, doc, view_mut!(editor, view_id)); + doc.append_changes_to_history(view_id); + }; + + if let Some(ref changes) = workspace_edit.changes { + log::debug!("workspace changes: {:?}", changes); + for (uri, text_edits) in changes { + let text_edits = text_edits.to_vec(); + apply_edits(uri, text_edits) + } + return; + // Not sure if it works properly, it'll be safer to just panic here to avoid breaking some parts of code on which code actions will be used + // TODO: find some example that uses workspace changes, and test it + // for (url, edits) in changes.iter() { + // let file_path = url.origin().ascii_serialization(); + // let file_path = std::path::PathBuf::from(file_path); + // let file = std::fs::File::open(file_path).unwrap(); + // let mut text = Rope::from_reader(file).unwrap(); + // let transaction = edits_to_changes(&text, edits); + // transaction.apply(&mut text); + // } + } + + if let Some(ref document_changes) = workspace_edit.document_changes { + match document_changes { + lsp::DocumentChanges::Edits(document_edits) => { + for document_edit in document_edits { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } + } + lsp::DocumentChanges::Operations(operations) => { + log::debug!("document changes - operations: {:?}", operations); + for operation in operations { + match operation { + lsp::DocumentChangeOperation::Op(op) => { + apply_document_resource_op(op).unwrap(); + } + + lsp::DocumentChangeOperation::Edit(document_edit) => { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } + } + } + } + } + } +} + +fn goto_impl( + editor: &mut Editor, + compositor: &mut Compositor, + locations: Vec, + offset_encoding: OffsetEncoding, +) { + let cwdir = std::env::current_dir().unwrap_or_default(); + + match locations.as_slice() { + [location] => { + jump_to_location(editor, location, offset_encoding, Action::Replace); + } + [] => { + editor.set_error("No definition found."); + } + _locations => { + let picker = FilePicker::new( + locations, + cwdir, + move |cx, location, action| { + jump_to_location(cx.editor, location, offset_encoding, action) + }, + move |_editor, location| Some(location_to_file_location(location)), + ); + compositor.push(Box::new(overlayed(picker))); + } + } +} + +fn to_locations(definitions: Option) -> Vec { + match definitions { + Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], + Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, + Some(lsp::GotoDefinitionResponse::Link(locations)) => locations + .into_iter() + .map(|location_link| lsp::Location { + uri: location_link.target_uri, + range: location_link.target_range, + }) + .collect(), + None => Vec::new(), + } +} + +pub fn goto_definition(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.goto_definition(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-definition"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn goto_type_definition(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.goto_type_definition(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-type-definition"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn goto_implementation(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.goto_implementation(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-implementation"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn goto_reference(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.goto_reference(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-reference"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option>| { + let items = response.unwrap_or_default(); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +#[derive(PartialEq, Eq)] +pub enum SignatureHelpInvoked { + Manual, + Automatic, +} + +pub fn signature_help(cx: &mut Context) { + signature_help_impl(cx, SignatureHelpInvoked::Manual) +} + +pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { + let (view, doc) = current!(cx.editor); + let was_manually_invoked = invoked == SignatureHelpInvoked::Manual; + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => { + // Do not show the message if signature help was invoked + // automatically on backspace, trigger characters, etc. + if was_manually_invoked { + cx.editor + .set_status("Language server not active for current buffer"); + } + return; + } + }; + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) { + Some(f) => f, + None => { + if was_manually_invoked { + cx.editor + .set_error("Language server does not support signature-help"); + } + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + let config = &editor.config(); + + if !(config.lsp.auto_signature_help + || SignatureHelp::visible_popup(compositor).is_some() + || was_manually_invoked) + { + return; + } + + // If the signature help invocation is automatic, don't show it outside of Insert Mode: + // it very probably means the server was a little slow to respond and the user has + // already moved on to something else, making a signature help popup will just be an + // annoyance, see https://github.com/helix-editor/helix/issues/3112 + if !was_manually_invoked && editor.mode != Mode::Insert { + return; + } + + let response = match response { + // According to the spec the response should be None if there + // are no signatures, but some servers don't follow this. + Some(s) if !s.signatures.is_empty() => s, + _ => { + compositor.remove(SignatureHelp::ID); + return; + } + }; + let doc = doc!(editor); + let language = doc.language_name().unwrap_or(""); + + let signature = match response + .signatures + .get(response.active_signature.unwrap_or(0) as usize) + { + Some(s) => s, + None => return, + }; + let mut contents = SignatureHelp::new( + signature.label.clone(), + language.to_string(), + Arc::clone(&editor.syn_loader), + ); + + let signature_doc = if config.lsp.display_signature_help_docs { + signature.documentation.as_ref().map(|doc| match doc { + lsp::Documentation::String(s) => s.clone(), + lsp::Documentation::MarkupContent(markup) => markup.value.clone(), + }) + } else { + None + }; + + contents.set_signature_doc(signature_doc); + + let active_param_range = || -> Option<(usize, usize)> { + let param_idx = signature + .active_parameter + .or(response.active_parameter) + .unwrap_or(0) as usize; + let param = signature.parameters.as_ref()?.get(param_idx)?; + match ¶m.label { + lsp::ParameterLabel::Simple(string) => { + let start = signature.label.find(string.as_str())?; + Some((start, start + string.len())) + } + lsp::ParameterLabel::LabelOffsets([start, end]) => { + // LS sends offsets based on utf-16 based string representation + // but highlighting in helix is done using byte offset. + use helix_core::str_utils::char_to_byte_idx; + let from = char_to_byte_idx(&signature.label, *start as usize); + let to = char_to_byte_idx(&signature.label, *end as usize); + Some((from, to)) + } + } + }; + contents.set_active_param_range(active_param_range()); + + let old_popup = compositor.find_id::>(SignatureHelp::ID); + let popup = Popup::new(SignatureHelp::ID, contents) + .position(old_popup.and_then(|p| p.get_position())) + .position_bias(Open::Above) + .ignore_escape_key(true); + compositor.replace_or_push(SignatureHelp::ID, popup); + }, + ); +} + +pub fn hover(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.text_document_hover(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support hover"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + if let Some(hover) = response { + // hover.contents / .range <- used for visualizing + + fn marked_string_to_markdown(contents: lsp::MarkedString) -> String { + match contents { + lsp::MarkedString::String(contents) => contents, + lsp::MarkedString::LanguageString(string) => { + if string.language == "markdown" { + string.value + } else { + format!("```{}\n{}\n```", string.language, string.value) + } + } + } + } + + let contents = match hover.contents { + lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents), + lsp::HoverContents::Array(contents) => contents + .into_iter() + .map(marked_string_to_markdown) + .collect::>() + .join("\n\n"), + lsp::HoverContents::Markup(contents) => contents.value, + }; + + // skip if contents empty + + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents).auto_close(true); + compositor.replace_or_push("hover", popup); + } + }, + ); +} + +pub fn rename_symbol(cx: &mut Context) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + let primary_selection = doc.selection(view.id).primary(); + let prefill = if primary_selection.len() > 1 { + primary_selection + } else { + use helix_core::textobject::{textobject_word, TextObject}; + textobject_word(text, primary_selection, TextObject::Inside, 1, false) + } + .fragment(text) + .into(); + ui::prompt_with_input( + cx, + "rename-to:".into(), + prefill, + None, + ui::completers::none, + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = + match language_server.rename_symbol(doc.identifier(), pos, input.to_string()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support symbol renaming"); + return; + } + }; + match block_on(future) { + Ok(edits) => apply_workspace_edit(cx.editor, offset_encoding, &edits), + Err(err) => cx.editor.set_error(err.to_string()), + } + }, + ); +} + +pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.text_document_document_highlight(doc.identifier(), pos, None) + { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support document highlight"); + return; + } + }; + + cx.callback( + future, + move |editor, _compositor, response: Option>| { + let document_highlights = match response { + Some(highlights) if !highlights.is_empty() => highlights, + _ => return, + }; + let (view, doc) = current!(editor); + let language_server = language_server!(editor, doc); + let offset_encoding = language_server.offset_encoding(); + let text = doc.text(); + let pos = doc.selection(view.id).primary().head; + + // We must find the range that contains our primary cursor to prevent our primary cursor to move + let mut primary_index = 0; + let ranges = document_highlights + .iter() + .filter_map(|highlight| lsp_range_to_range(text, highlight.range, offset_encoding)) + .enumerate() + .map(|(i, range)| { + if range.contains(pos) { + primary_index = i; + } + range + }) + .collect(); + let selection = Selection::new(ranges, primary_index); + doc.set_selection(view.id, selection); + }, + ); +} diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 15c91d62..acc895f2 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -506,7 +506,7 @@ fn earlier( let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.earlier(view, uk); + let success = doc.earlier(view.id, uk); if !success { cx.editor.set_status("Already at oldest change"); } @@ -525,7 +525,7 @@ fn later( let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.later(view, uk); + let success = doc.later(view.id, uk); if !success { cx.editor.set_status("Already at newest change"); } @@ -1059,6 +1059,51 @@ fn reload( }) } +fn reload_all( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let scrolloff = cx.editor.config().scrolloff; + let view_id = view!(cx.editor).id; + + let docs_view_ids: Vec<(DocumentId, Vec)> = cx + .editor + .documents_mut() + .map(|doc| { + let mut view_ids: Vec<_> = doc.selections().keys().cloned().collect(); + + if view_ids.is_empty() { + doc.ensure_view_init(view_id); + view_ids.push(view_id); + }; + + (doc.id(), view_ids) + }) + .collect(); + + for (doc_id, view_ids) in docs_view_ids { + let doc = doc_mut!(cx.editor, &doc_id); + + // Every doc is guaranteed to have at least 1 view at this point. + let view = view_mut!(cx.editor, view_ids[0]); + doc.reload(view)?; + + for view_id in view_ids { + let view = view_mut!(cx.editor, view_id); + if view.doc.eq(&doc_id) { + view.ensure_cursor_in_view(doc, scrolloff); + } + } + } + + Ok(()) +} + /// Update the [`Document`] if it has been modified. fn update( cx: &mut compositor::Context, @@ -1077,6 +1122,77 @@ fn update( } } +fn lsp_workspace_command( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (_, doc) = current!(cx.editor); + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => { + cx.editor + .set_status("Language server not active for current buffer"); + return Ok(()); + } + }; + + let options = match &language_server.capabilities().execute_command_provider { + Some(options) => options, + None => { + cx.editor + .set_status("Workspace commands are not supported for this language server"); + return Ok(()); + } + }; + if args.is_empty() { + let commands = options + .commands + .iter() + .map(|command| helix_lsp::lsp::Command { + title: command.clone(), + command: command.clone(), + arguments: None, + }) + .collect::>(); + let callback = async move { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |_editor: &mut Editor, compositor: &mut Compositor| { + let picker = ui::Picker::new(commands, (), |cx, command, _action| { + execute_lsp_command(cx.editor, command.clone()); + }); + compositor.push(Box::new(overlayed(picker))) + }, + )); + Ok(call) + }; + cx.jobs.callback(callback); + } else { + let command = args.join(" "); + if options.commands.iter().any(|c| c == &command) { + execute_lsp_command( + cx.editor, + helix_lsp::lsp::Command { + title: command.clone(), + arguments: None, + command, + }, + ); + } else { + cx.editor.set_status(format!( + "`{command}` is not supported for this language server" + )); + return Ok(()); + } + } + Ok(()) +} + fn lsp_restart( cx: &mut compositor::Context, _args: &[Cow], @@ -2012,6 +2128,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: reload, completer: None, }, + TypableCommand { + name: "reload-all", + aliases: &[], + doc: "Discard changes and reload all documents from the source files.", + fun: reload_all, + completer: None, + }, TypableCommand { name: "update", aliases: &[], @@ -2019,6 +2142,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: update, completer: None, }, + TypableCommand { + name: "lsp-workspace-command", + aliases: &[], + doc: "Open workspace command picker", + fun: lsp_workspace_command, + completer: Some(completers::lsp_workspace_command), + }, TypableCommand { name: "lsp-restart", aliases: &[], @@ -2214,7 +2344,10 @@ pub static TYPABLE_COMMAND_MAP: Lazy = Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - // simple heuristic: if there's no just one part, complete command name. - // if there's a space, per command completion kicks in. - // we use .this over split_whitespace() because we care about empty segments - if input.split(' ').count() <= 1 { + let shellwords = Shellwords::from(input); + let words = shellwords.words(); + + if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) { + // If the command has not been finished yet, complete commands. let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST .iter() .filter_map(|command| { @@ -2241,19 +2375,29 @@ pub(super) fn command_mode(cx: &mut Context) { .map(|(name, _)| (0.., name.into())) .collect() } else { - let parts = shellwords::shellwords(input); - let part = parts.last().unwrap(); + // Otherwise, use the command's completer and the last shellword + // as completion input. + let (part, part_len) = if words.len() == 1 || shellwords.ends_with_whitespace() { + (&Cow::Borrowed(""), 0) + } else { + ( + words.last().unwrap(), + shellwords.parts().last().unwrap().len(), + ) + }; if let Some(typed::TypableCommand { completer: Some(completer), .. - }) = typed::TYPABLE_COMMAND_MAP.get(&parts[0] as &str) + }) = typed::TYPABLE_COMMAND_MAP.get(&words[0] as &str) { completer(editor, part) .into_iter() .map(|(range, file)| { + let file = shellwords::escape(file); + // offset ranges to input - let offset = input.len() - part.len(); + let offset = input.len() - part_len; let range = (range.start + offset)..; (range, file) }) @@ -2279,7 +2423,8 @@ pub(super) fn command_mode(cx: &mut Context) { // Handle typable commands if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { - let args = shellwords::shellwords(input); + let shellwords = Shellwords::from(input); + let args = shellwords.words(); if let Err(e) = (cmd.fun)(cx, &args[1..], event) { cx.editor.set_error(format!("{}", e)); diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 971dc52d..9dad3620 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -4,8 +4,6 @@ use helix_core::Position; use helix_view::graphics::{CursorKind, Rect}; -#[cfg(feature = "integration")] -use tui::backend::TestBackend; use tui::buffer::Buffer as Surface; pub type Callback = Box; @@ -75,67 +73,28 @@ pub trait Component: Any + AnyComponent { } } -use anyhow::Context as AnyhowContext; -use tui::backend::Backend; - -#[cfg(not(feature = "integration"))] -use tui::backend::CrosstermBackend; - -#[cfg(not(feature = "integration"))] -use std::io::stdout; - -#[cfg(not(feature = "integration"))] -type Terminal = tui::terminal::Terminal>; - -#[cfg(feature = "integration")] -type Terminal = tui::terminal::Terminal; - pub struct Compositor { layers: Vec>, - terminal: Terminal, + area: Rect, pub(crate) last_picker: Option>, } impl Compositor { - pub fn new() -> anyhow::Result { - #[cfg(not(feature = "integration"))] - let backend = CrosstermBackend::new(stdout()); - - #[cfg(feature = "integration")] - let backend = TestBackend::new(120, 150); - - let terminal = Terminal::new(backend).context("build terminal")?; - Ok(Self { + pub fn new(area: Rect) -> Self { + Self { layers: Vec::new(), - terminal, + area, last_picker: None, - }) + } } pub fn size(&self) -> Rect { - self.terminal.size().expect("couldn't get terminal size") - } - - pub fn resize(&mut self, width: u16, height: u16) { - self.terminal - .resize(Rect::new(0, 0, width, height)) - .expect("Unable to resize terminal") + self.area } - pub fn save_cursor(&mut self) { - if self.terminal.cursor_kind() == CursorKind::Hidden { - self.terminal - .backend_mut() - .show_cursor(CursorKind::Block) - .ok(); - } - } - - pub fn load_cursor(&mut self) { - if self.terminal.cursor_kind() == CursorKind::Hidden { - self.terminal.backend_mut().hide_cursor().ok(); - } + pub fn resize(&mut self, area: Rect) { + self.area = area; } pub fn push(&mut self, mut layer: Box) { @@ -203,25 +162,10 @@ impl Compositor { consumed } - pub fn render(&mut self, cx: &mut Context) { - self.terminal - .autoresize() - .expect("Unable to determine terminal size"); - - // TODO: need to recalculate view tree if necessary - - let surface = self.terminal.current_buffer_mut(); - - let area = *surface.area(); - + pub fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { for layer in &mut self.layers { layer.render(area, surface, cx); } - - let (pos, kind) = self.cursor(area, cx.editor); - let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); - - self.terminal.draw(pos, kind).unwrap(); } pub fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 00253388..87066eec 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -66,7 +66,10 @@ impl menu::Item for CompletionItem { Some(lsp::CompletionItemKind::EVENT) => "event", Some(lsp::CompletionItemKind::OPERATOR) => "operator", Some(lsp::CompletionItemKind::TYPE_PARAMETER) => "type_param", - Some(kind) => unimplemented!("{:?}", kind), + Some(kind) => { + log::error!("Received unknown completion item kind: {:?}", kind); + "" + } None => "", }), // self.detail.as_deref().unwrap_or("") @@ -113,7 +116,8 @@ impl Completion { let edit = match edit { lsp::CompletionTextEdit::Edit(edit) => edit.clone(), lsp::CompletionTextEdit::InsertAndReplace(item) => { - unimplemented!("completion: insert_and_replace {:?}", item) + // TODO: support using "insert" instead of "replace" via user config + lsp::TextEdit::new(item.replace, item.new_text.clone()) } }; @@ -223,7 +227,7 @@ impl Completion { } }; }); - let popup = Popup::new(Self::ID, menu); + let popup = Popup::new(Self::ID, menu).with_scrollbar(false); let mut completion = Self { popup, start_offset, @@ -241,21 +245,13 @@ impl Completion { completion_item: lsp::CompletionItem, ) -> Option { let language_server = doc.language_server()?; - let completion_resolve_provider = language_server - .capabilities() - .completion_provider - .as_ref()? - .resolve_provider; - if completion_resolve_provider != Some(true) { - return None; - } - let future = language_server.resolve_completion_item(completion_item); + let future = language_server.resolve_completion_item(completion_item)?; let response = helix_lsp::block_on(future); match response { - Ok(completion_item) => Some(completion_item), + Ok(value) => serde_json::from_value(value).ok(), Err(err) => { - log::error!("execute LSP command: {}", err); + log::error!("Failed to resolve completion item: {}", err); None } } @@ -300,6 +296,12 @@ impl Completion { self.popup.contents().is_empty() } + fn replace_item(&mut self, old_item: lsp::CompletionItem, new_item: lsp::CompletionItem) { + self.popup.contents_mut().replace_option(old_item, new_item); + } + + /// Asynchronously requests that the currently selection completion item is + /// resolved through LSP `completionItem/resolve`. pub fn ensure_item_resolved(&mut self, cx: &mut commands::Context) -> bool { // > If computing full completion items is expensive, servers can additionally provide a // > handler for the completion item resolve request. ... @@ -309,16 +311,41 @@ impl Completion { // > 'completionItem/resolve' request is sent with the selected completion item as a parameter. // > The returned completion item should have the documentation property filled in. // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion - match self.popup.contents_mut().selection_mut() { - Some(item) if item.documentation.is_none() => { - let doc = doc!(cx.editor); - if let Some(resolved_item) = Self::resolve_completion_item(doc, item.clone()) { - *item = resolved_item; + let current_item = match self.popup.contents().selection() { + Some(item) if item.documentation.is_none() => item.clone(), + _ => return false, + }; + + let language_server = match doc!(cx.editor).language_server() { + Some(language_server) => language_server, + None => return false, + }; + + // This method should not block the compositor so we handle the response asynchronously. + let future = match language_server.resolve_completion_item(current_item.clone()) { + Some(future) => future, + None => return false, + }; + + cx.callback( + future, + move |_editor, compositor, response: Option| { + let resolved_item = match response { + Some(item) => item, + None => return, + }; + + if let Some(completion) = &mut compositor + .find::() + .unwrap() + .completion + { + completion.replace_item(current_item, resolved_item); } - true - } - _ => false, - } + }, + ); + + true } } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index d5d22151..2db40e1b 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -81,9 +81,10 @@ impl EditorView { surface: &mut Surface, is_focused: bool, ) { - let inner = view.inner_area(); + let inner = view.inner_area(doc); let area = view.area; let theme = &editor.theme; + let config = editor.config(); // DAP: Highlight current stack frame position let stack_frame = editor.debugger.as_ref().and_then(|debugger| { @@ -119,10 +120,10 @@ impl EditorView { } } - if is_focused && editor.config().cursorline { + if is_focused && config.cursorline { Self::highlight_cursorline(doc, view, surface, theme); } - if is_focused && editor.config().cursorcolumn { + if is_focused && config.cursorcolumn { Self::highlight_cursorcolumn(doc, view, surface, theme); } @@ -143,22 +144,14 @@ impl EditorView { doc, view, theme, - &editor.config().cursor_shape, + &config.cursor_shape, ), )) } else { Box::new(highlights) }; - Self::render_text_highlights( - doc, - view.offset, - inner, - surface, - theme, - highlights, - &editor.config(), - ); + Self::render_text_highlights(doc, view.offset, inner, surface, theme, highlights, &config); Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused); Self::render_rulers(editor, doc, view, inner, surface, theme); @@ -178,7 +171,7 @@ impl EditorView { } } - self.render_diagnostics(doc, view, inner, surface, theme); + Self::render_diagnostics(doc, view, inner, surface, theme); let statusline_area = view .area @@ -759,9 +752,10 @@ impl EditorView { // avoid lots of small allocations by reusing a text buffer for each line let mut text = String::with_capacity(8); - for (constructor, width) in view.gutters() { - let gutter = constructor(editor, doc, view, theme, is_focused, *width); - text.reserve(*width); // ensure there's enough space for the gutter + for gutter_type in view.gutters() { + let gutter = gutter_type.style(editor, doc, view, theme, is_focused); + let width = gutter_type.width(view, doc); + text.reserve(width); // ensure there's enough space for the gutter for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { let selected = cursors.contains(&line); let x = viewport.x + offset; @@ -774,13 +768,13 @@ impl EditorView { }; if let Some(style) = gutter(line, selected, &mut text) { - surface.set_stringn(x, y, &text, *width, gutter_style.patch(style)); + surface.set_stringn(x, y, &text, width, gutter_style.patch(style)); } else { surface.set_style( Rect { x, y, - width: *width as u16, + width: width as u16, height: 1, }, gutter_style, @@ -789,12 +783,11 @@ impl EditorView { text.clear(); } - offset += *width as u16; + offset += width as u16; } } pub fn render_diagnostics( - &self, doc: &Document, view: &View, viewport: Rect, @@ -905,7 +898,7 @@ impl EditorView { .or_else(|| theme.try_get_exact("ui.cursorcolumn")) .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); - let inner_area = view.inner_area(); + let inner_area = view.inner_area(doc); let offset = view.offset.col; let selection = doc.selection(view.id); @@ -1377,7 +1370,9 @@ impl Component for EditorView { cx.editor.status_msg = None; let mode = cx.editor.mode(); - let (view, _) = current!(cx.editor); + let (view, doc) = current!(cx.editor); + let original_doc_id = doc.id(); + let original_doc_revision = doc.get_current_revision(); let focus = view.id; if let Some(on_next_key) = self.on_next_key.take() { @@ -1454,13 +1449,31 @@ impl Component for EditorView { let view = view_mut!(cx.editor, focus); let doc = doc_mut!(cx.editor, &view.doc); - view.ensure_cursor_in_view(doc, config.scrolloff); - // Store a history state if not in insert mode. This also takes care of // committing changes when leaving insert mode. if mode != Mode::Insert { doc.append_changes_to_history(view.id); } + + // If the current document has been changed, apply the changes to all views. + // This ensures that selections in jumplists follow changes. + if doc.id() == original_doc_id + && doc.get_current_revision() != original_doc_revision + { + if let Some(transaction) = + doc.history.get_mut().changes_since(original_doc_revision) + { + let doc = doc!(cx.editor, &original_doc_id); + for (view, _focused) in cx.editor.tree.views_mut() { + view.apply(&transaction, doc); + } + } + } + + let view = view_mut!(cx.editor, focus); + let doc = doc_mut!(cx.editor, &view.doc); + + view.ensure_cursor_in_view(doc, config.scrolloff); } EventResult::Consumed(callback) diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index df03baf5..1e9b08e7 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -234,6 +234,17 @@ impl Menu { } } +impl Menu { + pub fn replace_option(&mut self, old_option: T, new_option: T) { + for option in &mut self.options { + if old_option == *option { + *option = new_option; + break; + } + } + } +} + use super::PromptEvent as MenuEvent; impl Component for Menu { @@ -330,11 +341,6 @@ impl Component for Menu { (a + b - 1) / b } - let scroll_height = std::cmp::min(div_ceil(win_height.pow(2), len), win_height as usize); - - let scroll_line = (win_height - scroll_height) * scroll - / std::cmp::max(1, len.saturating_sub(win_height)); - let rows = options.iter().map(|option| option.row(&self.editor_data)); let table = Table::new(rows) .style(style) @@ -367,20 +373,24 @@ impl Component for Menu { let fits = len <= win_height; let scroll_style = theme.get("ui.menu.scroll"); - for (i, _) in (scroll..(scroll + win_height).min(len)).enumerate() { - let cell = &mut surface[(area.x + area.width - 1, area.y + i as u16)]; + if !fits { + let scroll_height = div_ceil(win_height.pow(2), len).min(win_height); + let scroll_line = (win_height - scroll_height) * scroll + / std::cmp::max(1, len.saturating_sub(win_height)); - if !fits { - // Draw scroll track - cell.set_symbol("▐"); // right half block - cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset)); - } + let mut cell; + for i in 0..win_height { + cell = &mut surface[(area.right() - 1, area.top() + i as u16)]; - let is_marked = i >= scroll_line && i < scroll_line + scroll_height; + cell.set_symbol("▐"); // right half block - if !fits && is_marked { - // Draw scroll thumb - cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset)); + if scroll_line <= i && i < scroll_line + scroll_height { + // Draw scroll thumb + cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset)); + } else { + // Draw scroll track + cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset)); + } } } } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 2fede473..bd3ae587 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -234,7 +234,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi cx.editor.set_error(err); } }, - |_editor, path| Some((path.clone(), None)), + |_editor, path| Some((path.clone().into(), None)), ) } @@ -394,6 +394,45 @@ pub mod completers { .collect() } + pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec { + let matcher = Matcher::default(); + + let (_, doc) = current_ref!(editor); + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => { + return vec![]; + } + }; + + let options = match &language_server.capabilities().execute_command_provider { + Some(options) => options, + None => { + return vec![]; + } + }; + + let mut matches: Vec<_> = options + .commands + .iter() + .filter_map(|command| { + matcher + .fuzzy_match(command, input) + .map(|score| (command, score)) + }) + .collect(); + + matches.sort_unstable_by(|(command1, score1), (command2, score2)| { + (Reverse(*score1), command1).cmp(&(Reverse(*score2), command2)) + }); + + matches + .into_iter() + .map(|(command, _score)| ((0..), command.clone().into())) + .collect() + } + pub fn directory(editor: &Editor, input: &str) -> Vec { filename_impl(editor, input, |entry| { let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 2505f219..5e9ca3d8 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -11,20 +11,15 @@ use tui::{ use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use tui::widgets::Widget; -use std::time::Instant; -use std::{ - cmp::Reverse, - collections::HashMap, - io::Read, - path::{Path, PathBuf}, -}; +use std::{cmp::Ordering, time::Instant}; +use std::{collections::HashMap, io::Read, path::PathBuf}; use crate::ui::{Prompt, PromptEvent}; use helix_core::{movement::Direction, Position}; use helix_view::{ editor::Action, graphics::{CursorKind, Margin, Modifier, Rect}, - Document, Editor, + Document, DocumentId, Editor, }; use super::menu::Item; @@ -33,8 +28,36 @@ pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; +#[derive(PartialEq, Eq, Hash)] +pub enum PathOrId { + Id(DocumentId), + Path(PathBuf), +} + +impl PathOrId { + fn get_canonicalized(self) -> std::io::Result { + use PathOrId::*; + Ok(match self { + Path(path) => Path(helix_core::path::get_canonicalized_path(&path)?), + Id(id) => Id(id), + }) + } +} + +impl From for PathOrId { + fn from(v: PathBuf) -> Self { + Self::Path(v) + } +} + +impl From for PathOrId { + fn from(v: DocumentId) -> Self { + Self::Id(v) + } +} + /// File path and range of lines (used to align and highlight lines) -pub type FileLocation = (PathBuf, Option<(usize, usize)>); +pub type FileLocation = (PathOrId, Option<(usize, usize)>); pub struct FilePicker { picker: Picker, @@ -113,62 +136,71 @@ impl FilePicker { self.picker .selection() .and_then(|current| (self.file_fn)(editor, current)) - .and_then(|(path, line)| { - helix_core::path::get_canonicalized_path(&path) - .ok() - .zip(Some(line)) - }) + .and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line))) } /// Get (cached) preview for a given path. If a document corresponding /// to the path is already open in the editor, it is used instead. fn get_preview<'picker, 'editor>( &'picker mut self, - path: &Path, + path_or_id: PathOrId, editor: &'editor Editor, ) -> Preview<'picker, 'editor> { - if let Some(doc) = editor.document_by_path(path) { - return Preview::EditorDocument(doc); - } + match path_or_id { + PathOrId::Path(path) => { + let path = &path; + if let Some(doc) = editor.document_by_path(path) { + return Preview::EditorDocument(doc); + } - if self.preview_cache.contains_key(path) { - return Preview::Cached(&self.preview_cache[path]); - } + if self.preview_cache.contains_key(path) { + return Preview::Cached(&self.preview_cache[path]); + } - let data = std::fs::File::open(path).and_then(|file| { - let metadata = file.metadata()?; - // Read up to 1kb to detect the content type - let n = file.take(1024).read_to_end(&mut self.read_buffer)?; - let content_type = content_inspector::inspect(&self.read_buffer[..n]); - self.read_buffer.clear(); - Ok((metadata, content_type)) - }); - let preview = data - .map( - |(metadata, content_type)| match (metadata.len(), content_type) { - (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary, - (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => CachedPreview::LargeFile, - _ => { - // TODO: enable syntax highlighting; blocked by async rendering - Document::open(path, None, None) - .map(|doc| CachedPreview::Document(Box::new(doc))) - .unwrap_or(CachedPreview::NotFound) - } - }, - ) - .unwrap_or(CachedPreview::NotFound); - self.preview_cache.insert(path.to_owned(), preview); - Preview::Cached(&self.preview_cache[path]) + let data = std::fs::File::open(path).and_then(|file| { + let metadata = file.metadata()?; + // Read up to 1kb to detect the content type + let n = file.take(1024).read_to_end(&mut self.read_buffer)?; + let content_type = content_inspector::inspect(&self.read_buffer[..n]); + self.read_buffer.clear(); + Ok((metadata, content_type)) + }); + let preview = data + .map( + |(metadata, content_type)| match (metadata.len(), content_type) { + (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary, + (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => { + CachedPreview::LargeFile + } + _ => { + // TODO: enable syntax highlighting; blocked by async rendering + Document::open(path, None, None) + .map(|doc| CachedPreview::Document(Box::new(doc))) + .unwrap_or(CachedPreview::NotFound) + } + }, + ) + .unwrap_or(CachedPreview::NotFound); + self.preview_cache.insert(path.to_owned(), preview); + Preview::Cached(&self.preview_cache[path]) + } + PathOrId::Id(id) => { + let doc = editor.documents.get(&id).unwrap(); + Preview::EditorDocument(doc) + } + } } fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult { // Try to find a document in the cache let doc = self .current_file(cx.editor) - .and_then(|(path, _range)| self.preview_cache.get_mut(&path)) - .and_then(|cache| match cache { - CachedPreview::Document(doc) => Some(doc), - _ => None, + .and_then(|(path, _range)| match path { + PathOrId::Id(doc_id) => Some(doc_mut!(cx.editor, &doc_id)), + PathOrId::Path(path) => match self.preview_cache.get_mut(&path) { + Some(CachedPreview::Document(doc)) => Some(doc), + _ => None, + }, }); // Then attempt to highlight it if it has no language set @@ -225,7 +257,7 @@ impl Component for FilePicker { block.render(preview_area, surface); if let Some((path, range)) = self.current_file(cx.editor) { - let preview = self.get_preview(&path, cx.editor); + let preview = self.get_preview(path, cx.editor); let doc = match preview.document() { Some(doc) => doc, None => { @@ -309,13 +341,34 @@ impl Component for FilePicker { } } +#[derive(PartialEq, Eq, Debug)] +struct PickerMatch { + index: usize, + score: i64, + len: usize, +} + +impl PartialOrd for PickerMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PickerMatch { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .cmp(&other.score) + .reverse() + .then_with(|| self.len.cmp(&other.len)) + } +} + pub struct Picker { options: Vec, editor_data: T::Data, // filter: String, matcher: Box, - /// (index, score) - matches: Vec<(usize, i64)>, + matches: Vec, /// Current height of the completions box completion_height: u16, @@ -361,13 +414,16 @@ impl Picker { // scoring on empty input: // TODO: just reuse score() - picker.matches.extend( - picker - .options - .iter() - .enumerate() - .map(|(index, _option)| (index, 0)), - ); + picker + .matches + .extend(picker.options.iter().enumerate().map(|(index, option)| { + let text = option.filter_text(&picker.editor_data); + PickerMatch { + index, + score: 0, + len: text.chars().count(), + } + })); picker } @@ -384,32 +440,34 @@ impl Picker { if pattern.is_empty() { // Fast path for no pattern. self.matches.clear(); - self.matches.extend( - self.options - .iter() - .enumerate() - .map(|(index, _option)| (index, 0)), - ); + self.matches + .extend(self.options.iter().enumerate().map(|(index, option)| { + let text = option.filter_text(&self.editor_data); + PickerMatch { + index, + score: 0, + len: text.chars().count(), + } + })); } else if pattern.starts_with(&self.previous_pattern) { let query = FuzzyQuery::new(pattern); // optimization: if the pattern is a more specific version of the previous one // then we can score the filtered set. - self.matches.retain_mut(|(index, score)| { - let option = &self.options[*index]; + self.matches.retain_mut(|pmatch| { + let option = &self.options[pmatch.index]; let text = option.sort_text(&self.editor_data); match query.fuzzy_match(&text, &self.matcher) { Some(s) => { // Update the score - *score = s; + pmatch.score = s; true } None => false, } }); - self.matches - .sort_unstable_by_key(|(_, score)| Reverse(*score)); + self.matches.sort_unstable(); } else { let query = FuzzyQuery::new(pattern); self.matches.clear(); @@ -422,11 +480,14 @@ impl Picker { query .fuzzy_match(&text, &self.matcher) - .map(|score| (index, score)) + .map(|score| PickerMatch { + index, + score, + len: text.chars().count(), + }) }), ); - self.matches - .sort_unstable_by_key(|(_, score)| Reverse(*score)); + self.matches.sort_unstable(); } log::debug!("picker score {:?}", Instant::now().duration_since(now)); @@ -478,7 +539,7 @@ impl Picker { pub fn selection(&self) -> Option<&T> { self.matches .get(self.cursor) - .map(|(index, _score)| &self.options[*index]) + .map(|pmatch| &self.options[pmatch.index]) } pub fn toggle_preview(&mut self) { @@ -625,7 +686,7 @@ impl Component for Picker { .matches .iter() .skip(offset) - .map(|(index, _score)| (*index, self.options.get(*index).unwrap())); + .map(|pmatch| (pmatch.index, self.options.get(pmatch.index).unwrap())); for (i, (_index, option)) in files.take(rows as usize).enumerate() { let is_active = i == (self.cursor - offset); diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 3c140da4..62a6785a 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -22,6 +22,7 @@ pub struct Popup { auto_close: bool, ignore_escape_key: bool, id: &'static str, + has_scrollbar: bool, } impl Popup { @@ -37,6 +38,7 @@ impl Popup { auto_close: false, ignore_escape_key: false, id, + has_scrollbar: true, } } @@ -128,6 +130,14 @@ impl Popup { } } + /// Toggles the Popup's scrollbar. + /// Consider disabling the scrollbar in case the child + /// already has its own. + pub fn with_scrollbar(mut self, enable_scrollbar: bool) -> Self { + self.has_scrollbar = enable_scrollbar; + self + } + pub fn contents(&self) -> &T { &self.contents } @@ -228,6 +238,40 @@ impl Component for Popup { let inner = area.inner(&self.margin); self.contents.render(inner, surface, cx); + + // render scrollbar if contents do not fit + if self.has_scrollbar { + let win_height = inner.height as usize; + let len = self.child_size.1 as usize; + let fits = len <= win_height; + let scroll = self.scroll; + let scroll_style = cx.editor.theme.get("ui.menu.scroll"); + + const fn div_ceil(a: usize, b: usize) -> usize { + (a + b - 1) / b + } + + if !fits { + let scroll_height = div_ceil(win_height.pow(2), len).min(win_height); + let scroll_line = (win_height - scroll_height) * scroll + / std::cmp::max(1, len.saturating_sub(win_height)); + + let mut cell; + for i in 0..win_height { + cell = &mut surface[(inner.right() - 1, inner.top() + i as u16)]; + + cell.set_symbol("▐"); // right half block + + if scroll_line <= i && i < scroll_line + scroll_height { + // Draw scroll thumb + cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset)); + } else { + // Draw scroll track + cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset)); + } + } + } + } } fn id(&self) -> Option<&'static str> { diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index ca2872a7..b19b9a9f 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -1,6 +1,5 @@ use crate::compositor::{Component, Compositor, Context, Event, EventResult}; use crate::{alt, ctrl, key, shift, ui}; -use helix_core::shellwords; use helix_view::input::KeyEvent; use helix_view::keyboard::KeyCode; use std::{borrow::Cow, ops::RangeFrom}; @@ -295,23 +294,22 @@ impl Prompt { direction: CompletionDirection, ) { (self.callback_fn)(cx, &self.line, PromptEvent::Abort); - let register = cx.editor.registers.get_mut(register).read(); - - if register.is_empty() { - return; - } + let values = match cx.editor.registers.read(register) { + Some(values) if !values.is_empty() => values, + _ => return, + }; - let end = register.len().saturating_sub(1); + let end = values.len().saturating_sub(1); let index = match direction { CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1), CompletionDirection::Backward => { - self.history_pos.unwrap_or(register.len()).saturating_sub(1) + self.history_pos.unwrap_or(values.len()).saturating_sub(1) } } .min(end); - self.line = register[index].clone(); + self.line = values[index].clone(); self.history_pos = Some(index); @@ -336,10 +334,7 @@ impl Prompt { let (range, item) = &self.completion[index]; - // since we are using shellwords to parse arguments, make sure - // that whitespace in files is properly escaped. - let item = shellwords::escape(item); - self.line.replace_range(range.clone(), &item); + self.line.replace_range(range.clone(), item); self.move_end(); } @@ -552,10 +547,7 @@ impl Component for Prompt { if last_item != self.line { // store in history if let Some(register) = self.history_register { - cx.editor - .registers - .get_mut(register) - .push(self.line.clone()); + cx.editor.registers.push(register, self.line.clone()); }; } diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index b0e8ec5d..501faea3 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -1,4 +1,5 @@ use helix_core::{coords_at_pos, encoding, Position}; +use helix_lsp::lsp::DiagnosticSeverity; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, graphics::Rect, @@ -68,7 +69,9 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface // Left side of the status line. - let element_ids = &context.editor.config().statusline.left; + let config = context.editor.config(); + + let element_ids = &config.statusline.left; element_ids .iter() .map(|element_id| get_render_function(*element_id)) @@ -83,7 +86,7 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface // Right side of the status line. - let element_ids = &context.editor.config().statusline.right; + let element_ids = &config.statusline.right; element_ids .iter() .map(|element_id| get_render_function(*element_id)) @@ -101,7 +104,7 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface // Center of the status line. - let element_ids = &context.editor.config().statusline.center; + let element_ids = &config.statusline.center; element_ids .iter() .map(|element_id| get_render_function(*element_id)) @@ -141,7 +144,11 @@ where helix_view::editor::StatusLineElement::FileLineEnding => render_file_line_ending, helix_view::editor::StatusLineElement::FileType => render_file_type, helix_view::editor::StatusLineElement::Diagnostics => render_diagnostics, + helix_view::editor::StatusLineElement::WorkspaceDiagnostics => render_workspace_diagnostics, helix_view::editor::StatusLineElement::Selections => render_selections, + helix_view::editor::StatusLineElement::PrimarySelectionLength => { + render_primary_selection_length + } helix_view::editor::StatusLineElement::Position => render_position, helix_view::editor::StatusLineElement::PositionPercentage => render_position_percentage, helix_view::editor::StatusLineElement::TotalLineNumbers => render_total_line_numbers, @@ -155,7 +162,8 @@ where F: Fn(&mut RenderContext, String, Option