diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ef47a277..0d6fcb3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,12 +28,10 @@ jobs: profile: minimal override: true - - uses: Swatinem/rust-cache@v1 + - uses: Swatinem/rust-cache@v2 - name: Run cargo check - uses: actions-rs/cargo@v1 - with: - command: check + run: cargo check test: name: Test Suite @@ -46,12 +44,9 @@ jobs: uses: actions/checkout@v3 - name: Install stable toolchain - uses: helix-editor/rust-toolchain@v1 - with: - profile: minimal - override: true + uses: dtolnay/rust-toolchain@1.61 - - uses: Swatinem/rust-cache@v1 + - uses: Swatinem/rust-cache@v2 - name: Cache test tree-sitter grammar uses: actions/cache@v3 @@ -61,15 +56,10 @@ jobs: restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars- - name: Run cargo test - uses: actions-rs/cargo@v1 - with: - command: test - args: --workspace + run: cargo test --workspace - name: Run cargo integration-test - uses: actions-rs/cargo@v1 - with: - command: integration-test + run: cargo integration-test strategy: matrix: @@ -83,31 +73,20 @@ jobs: uses: actions/checkout@v3 - name: Install stable toolchain - uses: helix-editor/rust-toolchain@v1 + uses: dtolnay/rust-toolchain@1.61 with: - profile: minimal - override: true components: rustfmt, clippy - - uses: Swatinem/rust-cache@v1 + - uses: Swatinem/rust-cache@v2 - name: Run cargo fmt - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check + run: cargo fmt --all --check - name: Run cargo clippy - uses: actions-rs/cargo@v1 - with: - command: clippy - args: --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: Run cargo doc - uses: actions-rs/cargo@v1 - with: - command: doc - args: --no-deps --workspace --document-private-items + run: cargo doc --no-deps --workspace --document-private-items env: RUSTDOCFLAGS: -D warnings @@ -119,18 +98,15 @@ jobs: uses: actions/checkout@v3 - name: Install stable toolchain - uses: helix-editor/rust-toolchain@v1 - with: - profile: minimal - override: true + uses: dtolnay/rust-toolchain@1.61 - - uses: Swatinem/rust-cache@v1 + - uses: Swatinem/rust-cache@v2 + + - name: Validate queries + run: cargo xtask query-check - name: Generate docs - uses: actions-rs/cargo@v1 - with: - command: xtask - args: docgen + run: cargo xtask docgen - name: Check uncommitted documentation changes run: | @@ -139,23 +115,3 @@ jobs: || (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@v1 - - - name: Generate docs - uses: actions-rs/cargo@v1 - with: - command: xtask - args: query-check diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 2d37b36a..20035678 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -14,10 +14,10 @@ jobs: uses: actions/checkout@v3 - name: Install nix - uses: cachix/install-nix-action@v17 + uses: cachix/install-nix-action@v18 - name: Authenticate with Cachix - uses: cachix/cachix-action@v10 + uses: cachix/cachix-action@v12 with: name: helix authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} 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/msrv-rust-toolchain.toml b/.github/workflows/msrv-rust-toolchain.toml index 7a3ec50b..b169d31e 100644 --- a/.github/workflows/msrv-rust-toolchain.toml +++ b/.github/workflows/msrv-rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.59.0" +channel = "1.61.0" components = ["rustfmt", "rust-src"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7aca89b..9518a537 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,18 +26,12 @@ jobs: uses: actions/checkout@v3 - name: Install stable toolchain - uses: helix-editor/rust-toolchain@v1 - with: - profile: minimal - override: true + uses: dtolnay/rust-toolchain@stable - - 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 . @@ -50,6 +44,16 @@ jobs: dist: name: Dist needs: [fetch-grammars] + env: + # For some builds, we use cross to test on 32-bit and big-endian + # systems. + CARGO: cargo + # When CARGO is set to CROSS, this is set to `--target matrix.target`. + TARGET_FLAGS: + # When CARGO is set to CROSS, TARGET_DIR includes matrix.target. + TARGET_DIR: ./target + # Emit backtraces on panics. + RUST_BACKTRACE: 1 runs-on: ${{ matrix.os }} strategy: fail-fast: false # don't fail other jobs if one fails @@ -57,17 +61,17 @@ jobs: build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc include: - build: x86_64-linux - os: ubuntu-20.04 + os: ubuntu-latest rust: stable target: x86_64-unknown-linux-gnu cross: false - build: aarch64-linux - os: ubuntu-20.04 + os: ubuntu-latest rust: stable target: aarch64-unknown-linux-gnu cross: true - build: riscv64-linux - os: ubuntu-20.04 + os: ubuntu-latest rust: stable target: riscv64gc-unknown-linux-gnu cross: true @@ -77,7 +81,7 @@ jobs: target: x86_64-apple-darwin cross: false - build: x86_64-windows - os: windows-2019 + os: windows-latest rust: stable target: x86_64-pc-windows-msvc cross: false @@ -110,12 +114,10 @@ jobs: tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources - name: Install ${{ matrix.rust }} toolchain - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@master with: - profile: minimal toolchain: ${{ matrix.rust }} target: ${{ matrix.target }} - override: true # Install a pre-release version of Cross # TODO: We need to pre-install Cross because we need cross-rs/cross#591 to @@ -123,15 +125,20 @@ jobs: # 0.3.0, which includes cross-rs/cross#591, is released. - name: Install Cross if: "matrix.cross" - run: cargo install cross --git https://github.com/cross-rs/cross.git --rev 47df5c76e7cba682823a0b6aa6d95c17b31ba63a + run: | + cargo install cross --git https://github.com/cross-rs/cross.git --rev 47df5c76e7cba682823a0b6aa6d95c17b31ba63a + echo "CARGO=cross" >> $GITHUB_ENV + # echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV + # echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV + + - name: Show command used for Cargo + run: | + echo "cargo command is: ${{ env.CARGO }}" + echo "target flag is: ${{ env.TARGET_FLAGS }}" - name: Run cargo test - uses: actions-rs/cargo@v1 if: "!matrix.skip_tests" - with: - use-cross: ${{ matrix.cross }} - command: test - args: --release --locked --target ${{ matrix.target }} --workspace + run: ${{ env.CARGO }} test --release --locked --target ${{ matrix.target }} --workspace - name: Set profile.release.strip = true shell: bash @@ -142,11 +149,7 @@ jobs: EOF - name: Build release binary - uses: actions-rs/cargo@v1 - with: - use-cross: ${{ matrix.cross }} - command: build - args: --release --locked --target ${{ matrix.target }} + run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }} - name: Build AppImage shell: bash @@ -221,16 +224,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: | @@ -250,7 +243,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 @@ -270,7 +263,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 @@ -280,7 +273,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/CHANGELOG.md b/CHANGELOG.md index 56d85751..dc91c9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,289 @@ +# 22.12 (2022-12-06) + +This is a great big release filled with changes from a 99 contributors. A big _thank you_ to you all! + +As usual, the following is a summary of each of the changes since the last release. +For the full log, check out the [git log](https://github.com/helix-editor/helix/compare/22.08.1..22.12). + +Breaking changes: + +- Remove readline-like navigation bindings from the default insert mode keymap ([e12690e](https://github.com/helix-editor/helix/commit/e12690e), [#3811](https://github.com/helix-editor/helix/pull/3811), [#3827](https://github.com/helix-editor/helix/pull/3827), [#3915](https://github.com/helix-editor/helix/pull/3915), [#4088](https://github.com/helix-editor/helix/pull/4088)) +- Rename `append_to_line` as `insert_at_line_end` and `prepend_to_line` as `insert_at_line_start` ([#3753](https://github.com/helix-editor/helix/pull/3753)) +- Swap diagnostic picker and debug mode bindings in the space keymap ([#4229](https://github.com/helix-editor/helix/pull/4229)) +- Select newly inserted text on paste or from shell commands ([#4458](https://github.com/helix-editor/helix/pull/4458), [#4608](https://github.com/helix-editor/helix/pull/4608), [#4619](https://github.com/helix-editor/helix/pull/4619), [#4824](https://github.com/helix-editor/helix/pull/4824)) +- Select newly inserted surrounding characters on `ms` ([#4752](https://github.com/helix-editor/helix/pull/4752)) +- Exit select-mode after executing `replace_*` commands ([#4554](https://github.com/helix-editor/helix/pull/4554)) +- Exit select-mode after executing surround commands ([#4858](https://github.com/helix-editor/helix/pull/4858)) +- Change tree-sitter text-object keys ([#3782](https://github.com/helix-editor/helix/pull/3782)) +- Rename `fleetish` theme to `fleet_dark` ([#4997](https://github.com/helix-editor/helix/pull/4997)) + +Features: + +- Bufferline ([#2759](https://github.com/helix-editor/helix/pull/2759)) +- Support underline styles and colors ([#4061](https://github.com/helix-editor/helix/pull/4061), [98c121c](https://github.com/helix-editor/helix/commit/98c121c)) +- Inheritance for themes ([#3067](https://github.com/helix-editor/helix/pull/3067), [#4096](https://github.com/helix-editor/helix/pull/4096)) +- Cursorcolumn ([#4084](https://github.com/helix-editor/helix/pull/4084)) +- Overhauled system for writing files and quiting ([#2267](https://github.com/helix-editor/helix/pull/2267), [#4397](https://github.com/helix-editor/helix/pull/4397)) +- Autosave when terminal loses focus ([#3178](https://github.com/helix-editor/helix/pull/3178)) +- Use OSC52 as a fallback for the system clipboard ([#3220](https://github.com/helix-editor/helix/pull/3220)) +- Show git diffs in the gutter ([#3890](https://github.com/helix-editor/helix/pull/3890), [#5012](https://github.com/helix-editor/helix/pull/5012), [#4995](https://github.com/helix-editor/helix/pull/4995)) +- Add a logo ([dc1ec56](https://github.com/helix-editor/helix/commit/dc1ec56)) +- Multi-cursor completion ([#4496](https://github.com/helix-editor/helix/pull/4496)) + +Commands: + +- `file_picker_in_current_directory` (`F`) ([#3701](https://github.com/helix-editor/helix/pull/3701)) +- `:lsp-restart` to restart the current document's language server ([#3435](https://github.com/helix-editor/helix/pull/3435), [#3972](https://github.com/helix-editor/helix/pull/3972)) +- `join_selections_space` (`A-j`) which joins selections and selects the joining whitespace ([#3549](https://github.com/helix-editor/helix/pull/3549)) +- `:update` to write the current file if it is modified ([#4426](https://github.com/helix-editor/helix/pull/4426)) +- `:lsp-workspace-command` for picking LSP commands to execute ([#3140](https://github.com/helix-editor/helix/pull/3140)) +- `extend_prev_word_end` - the extend variant for `move_prev_word_end` ([7468fa2](https://github.com/helix-editor/helix/commit/7468fa2)) +- `make_search_word_bounded` which adds regex word boundaries to the current search register value ([#4322](https://github.com/helix-editor/helix/pull/4322)) +- `:reload-all` - `:reload` for all open buffers ([#4663](https://github.com/helix-editor/helix/pull/4663), [#4901](https://github.com/helix-editor/helix/pull/4901)) +- `goto_next_change` (`]g`), `goto_prev_change` (`[g`), `goto_first_change` (`[G`), `goto_last_change` (`]G`) textobjects for jumping between VCS changes ([#4650](https://github.com/helix-editor/helix/pull/4650)) + +Usability improvements and fixes: + +- Don't log 'LSP not defined' errors in the logfile ([1caba2d](https://github.com/helix-editor/helix/commit/1caba2d)) +- Look for the external formatter program before invoking it ([#3670](https://github.com/helix-editor/helix/pull/3670)) +- Don't send LSP didOpen events for documents without URLs ([44b4479](https://github.com/helix-editor/helix/commit/44b4479)) +- Fix off-by-one in `extend_line_above` command ([#3689](https://github.com/helix-editor/helix/pull/3689)) +- Use the original scroll offset when opening a split ([1acdfaa](https://github.com/helix-editor/helix/commit/1acdfaa)) +- Handle auto-formatting failures and save the file anyway ([#3684](https://github.com/helix-editor/helix/pull/3684)) +- Ensure the cursor is in view after `:reflow` ([#3733](https://github.com/helix-editor/helix/pull/3733)) +- Add default rulers and reflow config for git commit messages ([#3738](https://github.com/helix-editor/helix/pull/3738)) +- Improve grammar fetching and building output ([#3773](https://github.com/helix-editor/helix/pull/3773)) +- Add a `text` language to language completion ([cc47d3f](https://github.com/helix-editor/helix/commit/cc47d3f)) +- Improve error handling for `:set-language` ([e8add6f](https://github.com/helix-editor/helix/commit/e8add6f)) +- Improve error handling for `:config-reload` ([#3668](https://github.com/helix-editor/helix/pull/3668)) +- Improve error handling when passing improper ranges to syntax highlighting ([#3826](https://github.com/helix-editor/helix/pull/3826)) +- Render `` tags as raw markup in markdown ([#3425](https://github.com/helix-editor/helix/pull/3425)) +- Remove border around the LSP code-actions popup ([#3444](https://github.com/helix-editor/helix/pull/3444)) +- Canonicalize the path to the runtime directory ([#3794](https://github.com/helix-editor/helix/pull/3794)) +- Add a `themelint` xtask for linting themes ([#3234](https://github.com/helix-editor/helix/pull/3234)) +- Re-sort LSP diagnostics after applying transactions ([#3895](https://github.com/helix-editor/helix/pull/3895), [#4319](https://github.com/helix-editor/helix/pull/4319)) +- Add a command-line flag to specify the log file ([#3807](https://github.com/helix-editor/helix/pull/3807)) +- Track source and tag information in LSP diagnostics ([#3898](https://github.com/helix-editor/helix/pull/3898), [1df32c9](https://github.com/helix-editor/helix/commit/1df32c9)) +- Fix theme returning to normal when exiting the `:theme` completion ([#3644](https://github.com/helix-editor/helix/pull/3644)) +- Improve error messages for invalid commands in the keymap ([#3931](https://github.com/helix-editor/helix/pull/3931)) +- Deduplicate regexs in `search_selection` command ([#3941](https://github.com/helix-editor/helix/pull/3941)) +- Split the finding of LSP root and config roots ([#3929](https://github.com/helix-editor/helix/pull/3929)) +- Ensure that the cursor is within view after auto-formatting ([#4047](https://github.com/helix-editor/helix/pull/4047)) +- Add pseudo-pending to commands with on-next-key callbacks ([#4062](https://github.com/helix-editor/helix/pull/4062), [#4077](https://github.com/helix-editor/helix/pull/4077)) +- Add live preview to `:goto` ([#2982](https://github.com/helix-editor/helix/pull/2982)) +- Show regex compilation failure in a popup ([#3049](https://github.com/helix-editor/helix/pull/3049)) +- Add 'cycled to end' and 'no more matches' for search ([#3176](https://github.com/helix-editor/helix/pull/3176), [#4101](https://github.com/helix-editor/helix/pull/4101)) +- Add extending behavior to tree-sitter textobjects ([#3266](https://github.com/helix-editor/helix/pull/3266)) +- Add `ui.gutter.selected` option for themes ([#3303](https://github.com/helix-editor/helix/pull/3303)) +- Make statusline mode names configurable ([#3311](https://github.com/helix-editor/helix/pull/3311)) +- Add a statusline element for total line count ([#3960](https://github.com/helix-editor/helix/pull/3960)) +- Add extending behavior to `goto_window_*` commands ([#3985](https://github.com/helix-editor/helix/pull/3985)) +- Fix a panic in signature help when the preview is too large ([#4030](https://github.com/helix-editor/helix/pull/4030)) +- Add command names to the command palette ([#4071](https://github.com/helix-editor/helix/pull/4071), [#4223](https://github.com/helix-editor/helix/pull/4223), [#4495](https://github.com/helix-editor/helix/pull/4495)) +- Find the LSP workspace root from the current document's path ([#3553](https://github.com/helix-editor/helix/pull/3553)) +- Add an option to skip indent-guide levels ([#3819](https://github.com/helix-editor/helix/pull/3819), [2c36e33](https://github.com/helix-editor/helix/commit/2c36e33)) +- Change focus to modified docs on quit ([#3872](https://github.com/helix-editor/helix/pull/3872)) +- Respond to `USR1` signal by reloading config ([#3952](https://github.com/helix-editor/helix/pull/3952)) +- Exit gracefully when the close operation fails ([#4081](https://github.com/helix-editor/helix/pull/4081)) +- Fix goto/view center mismatch ([#4135](https://github.com/helix-editor/helix/pull/4135)) +- Highlight the current file picker document on idle-timeout ([#3172](https://github.com/helix-editor/helix/pull/3172), [a85e386](https://github.com/helix-editor/helix/commit/a85e386)) +- Apply transactions to jumplist selections ([#4186](https://github.com/helix-editor/helix/pull/4186), [#4227](https://github.com/helix-editor/helix/pull/4227), [#4733](https://github.com/helix-editor/helix/pull/4733), [#4865](https://github.com/helix-editor/helix/pull/4865), [#4912](https://github.com/helix-editor/helix/pull/4912), [#4965](https://github.com/helix-editor/helix/pull/4965), [#4981](https://github.com/helix-editor/helix/pull/4981)) +- Use space as a separator for fuzzy matcher ([#3969](https://github.com/helix-editor/helix/pull/3969)) +- Overlay all diagnostics with highest severity on top ([#4113](https://github.com/helix-editor/helix/pull/4113)) +- Avoid re-parsing unmodified tree-sitter injections ([#4146](https://github.com/helix-editor/helix/pull/4146)) +- Add extending captures for indentation, re-enable python indentation ([#3382](https://github.com/helix-editor/helix/pull/3382), [3e84434](https://github.com/helix-editor/helix/commit/3e84434)) +- Only allow either `--vsplit` or `--hsplit` CLI flags at once ([#4202](https://github.com/helix-editor/helix/pull/4202)) +- Fix append cursor location when selection anchor is at the end of the document ([#4147](https://github.com/helix-editor/helix/pull/4147)) +- Improve selection yanking message ([#4275](https://github.com/helix-editor/helix/pull/4275)) +- Log failures to load tree-sitter grammars as errors ([#4315](https://github.com/helix-editor/helix/pull/4315)) +- Fix rendering of lines longer than 65,536 columns ([#4172](https://github.com/helix-editor/helix/pull/4172)) +- Skip searching `.git` in `global_search` ([#4334](https://github.com/helix-editor/helix/pull/4334)) +- Display tree-sitter scopes in a popup ([#4337](https://github.com/helix-editor/helix/pull/4337)) +- Fix deleting a word from the end of the buffer ([#4328](https://github.com/helix-editor/helix/pull/4328)) +- Pretty print the syntax tree in `:tree-sitter-subtree` ([#4295](https://github.com/helix-editor/helix/pull/4295), [#4606](https://github.com/helix-editor/helix/pull/4606)) +- Allow specifying suffixes for file-type detection ([#2455](https://github.com/helix-editor/helix/pull/2455), [#4414](https://github.com/helix-editor/helix/pull/4414)) +- Fix multi-byte auto-pairs ([#4024](https://github.com/helix-editor/helix/pull/4024)) +- Improve sort scoring for LSP code-actions and completions ([#4134](https://github.com/helix-editor/helix/pull/4134)) +- Fix the handling of quotes within shellwords ([#4098](https://github.com/helix-editor/helix/pull/4098)) +- Fix `delete_word_backward` and `delete_word_forward` on newlines ([#4392](https://github.com/helix-editor/helix/pull/4392)) +- Fix 'no entry found for key' crash on `:write-all` ([#4384](https://github.com/helix-editor/helix/pull/4384)) +- Remove lowercase requirement for tree-sitter grammars ([#4346](https://github.com/helix-editor/helix/pull/4346)) +- Resolve LSP completion items on idle-timeout ([#4406](https://github.com/helix-editor/helix/pull/4406), [#4797](https://github.com/helix-editor/helix/pull/4797)) +- Render diagnostics in the file picker preview ([#4324](https://github.com/helix-editor/helix/pull/4324)) +- Fix terminal freezing on `shell_insert_output` ([#4156](https://github.com/helix-editor/helix/pull/4156)) +- Allow use of the count in the repeat operator (`.`) ([#4450](https://github.com/helix-editor/helix/pull/4450)) +- Show the current theme name on `:theme` with no arguments ([#3740](https://github.com/helix-editor/helix/pull/3740)) +- Fix rendering in very large terminals ([#4318](https://github.com/helix-editor/helix/pull/4318)) +- Sort LSP preselected items to the top of the completion menu ([#4480](https://github.com/helix-editor/helix/pull/4480)) +- Trim braces and quotes from paths in goto-file ([#4370](https://github.com/helix-editor/helix/pull/4370)) +- Prevent automatic signature help outside of insert mode ([#4456](https://github.com/helix-editor/helix/pull/4456)) +- Fix freezes with external programs that process stdin and stdout concurrently ([#4180](https://github.com/helix-editor/helix/pull/4180)) +- Make `scroll` aware of tabs and wide characters ([#4519](https://github.com/helix-editor/helix/pull/4519)) +- Correctly handle escaping in `command_mode` completion ([#4316](https://github.com/helix-editor/helix/pull/4316), [#4587](https://github.com/helix-editor/helix/pull/4587), [#4632](https://github.com/helix-editor/helix/pull/4632)) +- Fix `delete_char_backward` for paired characters ([#4558](https://github.com/helix-editor/helix/pull/4558)) +- Fix crash from two windows editing the same document ([#4570](https://github.com/helix-editor/helix/pull/4570)) +- Fix pasting from the blackhole register ([#4497](https://github.com/helix-editor/helix/pull/4497)) +- Support LSP insertReplace completion items ([1312682](https://github.com/helix-editor/helix/commit/1312682)) +- Dynamically resize the line number gutter width ([#3469](https://github.com/helix-editor/helix/pull/3469)) +- Fix crash for unknown completion item kinds ([#4658](https://github.com/helix-editor/helix/pull/4658)) +- Re-enable `format_selections` for single selection ranges ([d4f5cab](https://github.com/helix-editor/helix/commit/d4f5cab)) +- Limit the number of in-progress tree-sitter query matches ([#4707](https://github.com/helix-editor/helix/pull/4707), [#4830](https://github.com/helix-editor/helix/pull/4830)) +- Use the special `#` register with `increment`/`decrement` to change by range number ([#4418](https://github.com/helix-editor/helix/pull/4418)) +- Add a statusline element to show number of selected chars ([#4682](https://github.com/helix-editor/helix/pull/4682)) +- Add a statusline element showing global LSP diagnostic warning and error counts ([#4569](https://github.com/helix-editor/helix/pull/4569)) +- Add a scrollbar to popups ([#4449](https://github.com/helix-editor/helix/pull/4449)) +- Prefer shorter matches in fuzzy matcher scoring ([#4698](https://github.com/helix-editor/helix/pull/4698)) +- Use key-sequence format for command palette keybinds ([#4712](https://github.com/helix-editor/helix/pull/4712)) +- Remove prefix filtering from autocompletion menu ([#4578](https://github.com/helix-editor/helix/pull/4578)) +- Focus on the parent buffer when closing a split ([#4766](https://github.com/helix-editor/helix/pull/4766)) +- Handle language server termination ([#4797](https://github.com/helix-editor/helix/pull/4797), [#4852](https://github.com/helix-editor/helix/pull/4852)) +- Allow `r`/`t`/`f` to work on tab characters ([#4817](https://github.com/helix-editor/helix/pull/4817)) +- Show a preview for scratch buffers in the buffer picker ([#3454](https://github.com/helix-editor/helix/pull/3454)) +- Set a limit of entries in the jumplist ([#4750](https://github.com/helix-editor/helix/pull/4750)) +- Re-use shell outputs when inserting or appending shell output ([#3465](https://github.com/helix-editor/helix/pull/3465)) +- Check LSP server provider capabilities ([#3554](https://github.com/helix-editor/helix/pull/3554)) +- Improve tree-sitter parsing performance on files with many language layers ([#4716](https://github.com/helix-editor/helix/pull/4716)) +- Move indentation to the next line when using `` on a line with only whitespace ([#4854](https://github.com/helix-editor/helix/pull/4854)) +- Remove selections for closed views from all documents ([#4888](https://github.com/helix-editor/helix/pull/4888)) +- Improve performance of the `:reload` command ([#4457](https://github.com/helix-editor/helix/pull/4457)) +- Properly handle media keys ([#4887](https://github.com/helix-editor/helix/pull/4887)) +- Support LSP diagnostic data field ([#4935](https://github.com/helix-editor/helix/pull/4935)) +- Handle C-i keycode as tab ([#4961](https://github.com/helix-editor/helix/pull/4961)) +- Fix view alignment for jumplist picker jumps ([#3743](https://github.com/helix-editor/helix/pull/3743)) +- Use OSC52 for tmux clipboard provider ([#5027](https://github.com/helix-editor/helix/pull/5027)) + +Themes: + +- Add `varua` ([#3610](https://github.com/helix-editor/helix/pull/3610), [#4964](https://github.com/helix-editor/helix/pull/4964)) +- Update `boo_berry` ([#3653](https://github.com/helix-editor/helix/pull/3653)) +- Add `rasmus` ([#3728](https://github.com/helix-editor/helix/pull/3728)) +- Add `papercolor_dark` ([#3742](https://github.com/helix-editor/helix/pull/3742)) +- Update `monokai_pro_spectrum` ([#3814](https://github.com/helix-editor/helix/pull/3814)) +- Update `nord` ([#3792](https://github.com/helix-editor/helix/pull/3792)) +- Update `fleetish` ([#3844](https://github.com/helix-editor/helix/pull/3844), [#4487](https://github.com/helix-editor/helix/pull/4487), [#4813](https://github.com/helix-editor/helix/pull/4813)) +- Update `flatwhite` ([#3843](https://github.com/helix-editor/helix/pull/3843)) +- Add `darcula` ([#3739](https://github.com/helix-editor/helix/pull/3739)) +- Update `papercolor` ([#3938](https://github.com/helix-editor/helix/pull/3938), [#4317](https://github.com/helix-editor/helix/pull/4317)) +- Add bufferline colors to multiple themes ([#3881](https://github.com/helix-editor/helix/pull/3881)) +- Add `gruvbox_dark_hard` ([#3948](https://github.com/helix-editor/helix/pull/3948)) +- Add `onedarker` ([#3980](https://github.com/helix-editor/helix/pull/3980), [#4060](https://github.com/helix-editor/helix/pull/4060)) +- Add `dark_high_contrast` ([#3312](https://github.com/helix-editor/helix/pull/3312)) +- Update `bogster` ([#4121](https://github.com/helix-editor/helix/pull/4121), [#4264](https://github.com/helix-editor/helix/pull/4264)) +- Update `sonokai` ([#4089](https://github.com/helix-editor/helix/pull/4089)) +- Update `ayu_*` themes ([#4140](https://github.com/helix-editor/helix/pull/4140), [#4109](https://github.com/helix-editor/helix/pull/4109), [#4662](https://github.com/helix-editor/helix/pull/4662), [#4764](https://github.com/helix-editor/helix/pull/4764)) +- Update `everforest` ([#3998](https://github.com/helix-editor/helix/pull/3998)) +- Update `monokai_pro_octagon` ([#4247](https://github.com/helix-editor/helix/pull/4247)) +- Add `heisenberg` ([#4209](https://github.com/helix-editor/helix/pull/4209)) +- Add `bogster_light` ([#4265](https://github.com/helix-editor/helix/pull/4265)) +- Update `pop-dark` ([#4323](https://github.com/helix-editor/helix/pull/4323)) +- Update `rose_pine` ([#4221](https://github.com/helix-editor/helix/pull/4221)) +- Add `kanagawa` ([#4300](https://github.com/helix-editor/helix/pull/4300)) +- Add `hex_steel`, `hex_toxic` and `hex_lavendar` ([#4367](https://github.com/helix-editor/helix/pull/4367), [#4990](https://github.com/helix-editor/helix/pull/4990)) +- Update `tokyonight` and `tokyonight_storm` ([#4415](https://github.com/helix-editor/helix/pull/4415)) +- Update `gruvbox` ([#4626](https://github.com/helix-editor/helix/pull/4626)) +- Update `dark_plus` ([#4661](https://github.com/helix-editor/helix/pull/4661), [#4678](https://github.com/helix-editor/helix/pull/4678)) +- Add `zenburn` ([#4613](https://github.com/helix-editor/helix/pull/4613), [#4977](https://github.com/helix-editor/helix/pull/4977)) +- Update `monokai_pro` ([#4789](https://github.com/helix-editor/helix/pull/4789)) +- Add `mellow` ([#4770](https://github.com/helix-editor/helix/pull/4770)) +- Add `nightfox` ([#4769](https://github.com/helix-editor/helix/pull/4769), [#4966](https://github.com/helix-editor/helix/pull/4966)) +- Update `doom_acario_dark` ([#4979](https://github.com/helix-editor/helix/pull/4979)) +- Update `autumn` ([#4996](https://github.com/helix-editor/helix/pull/4996)) +- Update `acme` ([#4999](https://github.com/helix-editor/helix/pull/4999)) +- Update `nord_light` ([#4999](https://github.com/helix-editor/helix/pull/4999)) +- Update `serika_*` ([#5015](https://github.com/helix-editor/helix/pull/5015)) + +LSP configurations: + +- Switch to `openscad-lsp` for OpenScad ([#3750](https://github.com/helix-editor/helix/pull/3750)) +- Support Jsonnet ([#3748](https://github.com/helix-editor/helix/pull/3748)) +- Support Markdown ([#3499](https://github.com/helix-editor/helix/pull/3499)) +- Support Bass ([#3771](https://github.com/helix-editor/helix/pull/3771)) +- Set roots configuration for Elixir and HEEx ([#3917](https://github.com/helix-editor/helix/pull/3917), [#3959](https://github.com/helix-editor/helix/pull/3959)) +- Support Purescript ([#4242](https://github.com/helix-editor/helix/pull/4242)) +- Set roots configuration for Julia ([#4361](https://github.com/helix-editor/helix/pull/4361)) +- Support D ([#4372](https://github.com/helix-editor/helix/pull/4372)) +- Increase default language server timeout for Julia ([#4575](https://github.com/helix-editor/helix/pull/4575)) +- Use ElixirLS for HEEx ([#4679](https://github.com/helix-editor/helix/pull/4679)) +- Support Bicep ([#4403](https://github.com/helix-editor/helix/pull/4403)) +- Switch to `nil` for Nix ([433ccef](https://github.com/helix-editor/helix/commit/433ccef)) +- Support QML ([#4842](https://github.com/helix-editor/helix/pull/4842)) +- Enable auto-format for CSS ([#4987](https://github.com/helix-editor/helix/pull/4987)) +- Support CommonLisp ([4176769](https://github.com/helix-editor/helix/commit/4176769)) + +New languages: + +- SML ([#3692](https://github.com/helix-editor/helix/pull/3692)) +- Jsonnet ([#3714](https://github.com/helix-editor/helix/pull/3714)) +- Godot resource ([#3759](https://github.com/helix-editor/helix/pull/3759)) +- Astro ([#3829](https://github.com/helix-editor/helix/pull/3829)) +- SSH config ([#2455](https://github.com/helix-editor/helix/pull/2455), [#4538](https://github.com/helix-editor/helix/pull/4538)) +- Bass ([#3771](https://github.com/helix-editor/helix/pull/3771)) +- WAT (WebAssembly text format) ([#4040](https://github.com/helix-editor/helix/pull/4040), [#4542](https://github.com/helix-editor/helix/pull/4542)) +- Purescript ([#4242](https://github.com/helix-editor/helix/pull/4242)) +- D ([#4372](https://github.com/helix-editor/helix/pull/4372), [#4562](https://github.com/helix-editor/helix/pull/4562)) +- VHS ([#4486](https://github.com/helix-editor/helix/pull/4486)) +- KDL ([#4481](https://github.com/helix-editor/helix/pull/4481)) +- XML ([#4518](https://github.com/helix-editor/helix/pull/4518)) +- WIT ([#4525](https://github.com/helix-editor/helix/pull/4525)) +- ENV ([#4536](https://github.com/helix-editor/helix/pull/4536)) +- INI ([#4538](https://github.com/helix-editor/helix/pull/4538)) +- Bicep ([#4403](https://github.com/helix-editor/helix/pull/4403), [#4751](https://github.com/helix-editor/helix/pull/4751)) +- QML ([#4842](https://github.com/helix-editor/helix/pull/4842)) +- CommonLisp ([4176769](https://github.com/helix-editor/helix/commit/4176769)) + +Updated languages and queries: + +- Zig ([#3621](https://github.com/helix-editor/helix/pull/3621), [#4745](https://github.com/helix-editor/helix/pull/4745)) +- Rust ([#3647](https://github.com/helix-editor/helix/pull/3647), [#3729](https://github.com/helix-editor/helix/pull/3729), [#3927](https://github.com/helix-editor/helix/pull/3927), [#4073](https://github.com/helix-editor/helix/pull/4073), [#4510](https://github.com/helix-editor/helix/pull/4510), [#4659](https://github.com/helix-editor/helix/pull/4659), [#4717](https://github.com/helix-editor/helix/pull/4717)) +- Solidity ([20ed8c2](https://github.com/helix-editor/helix/commit/20ed8c2)) +- Fish ([#3704](https://github.com/helix-editor/helix/pull/3704)) +- Elixir ([#3645](https://github.com/helix-editor/helix/pull/3645), [#4333](https://github.com/helix-editor/helix/pull/4333), [#4821](https://github.com/helix-editor/helix/pull/4821)) +- Diff ([#3708](https://github.com/helix-editor/helix/pull/3708)) +- Nix ([665e27f](https://github.com/helix-editor/helix/commit/665e27f), [1fe3273](https://github.com/helix-editor/helix/commit/1fe3273)) +- Markdown ([#3749](https://github.com/helix-editor/helix/pull/3749), [#4078](https://github.com/helix-editor/helix/pull/4078), [#4483](https://github.com/helix-editor/helix/pull/4483), [#4478](https://github.com/helix-editor/helix/pull/4478)) +- GDScript ([#3760](https://github.com/helix-editor/helix/pull/3760)) +- JSX and TSX ([#3853](https://github.com/helix-editor/helix/pull/3853), [#3973](https://github.com/helix-editor/helix/pull/3973)) +- Ruby ([#3976](https://github.com/helix-editor/helix/pull/3976), [#4601](https://github.com/helix-editor/helix/pull/4601)) +- R ([#4031](https://github.com/helix-editor/helix/pull/4031)) +- WGSL ([#3996](https://github.com/helix-editor/helix/pull/3996), [#4079](https://github.com/helix-editor/helix/pull/4079)) +- C# ([#4118](https://github.com/helix-editor/helix/pull/4118), [#4281](https://github.com/helix-editor/helix/pull/4281), [#4213](https://github.com/helix-editor/helix/pull/4213)) +- Twig ([#4176](https://github.com/helix-editor/helix/pull/4176)) +- Lua ([#3552](https://github.com/helix-editor/helix/pull/3552)) +- C/C++ ([#4079](https://github.com/helix-editor/helix/pull/4079), [#4278](https://github.com/helix-editor/helix/pull/4278), [#4282](https://github.com/helix-editor/helix/pull/4282)) +- Cairo ([17488f1](https://github.com/helix-editor/helix/commit/17488f1), [431f9c1](https://github.com/helix-editor/helix/commit/431f9c1), [09a6df1](https://github.com/helix-editor/helix/commit/09a6df1)) +- Rescript ([#4356](https://github.com/helix-editor/helix/pull/4356)) +- Zig ([#4409](https://github.com/helix-editor/helix/pull/4409)) +- Scala ([#4353](https://github.com/helix-editor/helix/pull/4353), [#4697](https://github.com/helix-editor/helix/pull/4697), [#4701](https://github.com/helix-editor/helix/pull/4701)) +- LaTeX ([#4528](https://github.com/helix-editor/helix/pull/4528), [#4922](https://github.com/helix-editor/helix/pull/4922)) +- SQL ([#4529](https://github.com/helix-editor/helix/pull/4529)) +- Python ([#4560](https://github.com/helix-editor/helix/pull/4560)) +- Bash/Zsh ([#4582](https://github.com/helix-editor/helix/pull/4582)) +- Nu ([#4583](https://github.com/helix-editor/helix/pull/4583)) +- Julia ([#4588](https://github.com/helix-editor/helix/pull/4588)) +- Typescript ([#4703](https://github.com/helix-editor/helix/pull/4703)) +- Meson ([#4572](https://github.com/helix-editor/helix/pull/4572)) +- Haskell ([#4800](https://github.com/helix-editor/helix/pull/4800)) +- CMake ([#4809](https://github.com/helix-editor/helix/pull/4809)) +- HTML ([#4829](https://github.com/helix-editor/helix/pull/4829), [#4881](https://github.com/helix-editor/helix/pull/4881)) +- Java ([#4886](https://github.com/helix-editor/helix/pull/4886)) +- Go ([#4906](https://github.com/helix-editor/helix/pull/4906), [#4969](https://github.com/helix-editor/helix/pull/4969), [#5010](https://github.com/helix-editor/helix/pull/5010)) +- CSS ([#4882](https://github.com/helix-editor/helix/pull/4882)) +- Racket ([#4915](https://github.com/helix-editor/helix/pull/4915)) +- SCSS ([#5003](https://github.com/helix-editor/helix/pull/5003)) + +Packaging: + +- Filter relevant source files in the Nix flake ([#3657](https://github.com/helix-editor/helix/pull/3657)) +- Build a binary for `aarch64-linux` in the release CI ([038a91d](https://github.com/helix-editor/helix/commit/038a91d)) +- Build an AppImage for `aarch64-linux` in the release CI ([b738031](https://github.com/helix-editor/helix/commit/b738031)) +- Enable CI builds for `riscv64-linux` ([#3685](https://github.com/helix-editor/helix/pull/3685)) +- Support preview releases in CI ([0090a2d](https://github.com/helix-editor/helix/commit/0090a2d)) +- Strip binaries built in CI ([#3780](https://github.com/helix-editor/helix/pull/3780)) +- Fix the development shell for the Nix Flake on `aarch64-darwin` ([#3810](https://github.com/helix-editor/helix/pull/3810)) +- Raise the MSRV and create an MSRV policy ([#3896](https://github.com/helix-editor/helix/pull/3896), [#3913](https://github.com/helix-editor/helix/pull/3913), [#3961](https://github.com/helix-editor/helix/pull/3961)) +- Fix Fish completions for `--config` and `--log` flags ([#3912](https://github.com/helix-editor/helix/pull/3912)) +- Use builtin filenames option in Bash completion ([#4648](https://github.com/helix-editor/helix/pull/4648)) + # 22.08.1 (2022-09-01) This is a patch release that fixes a panic caused by closing splits or buffers. ([#3633](https://github.com/helix-editor/helix/pull/3633)) diff --git a/Cargo.lock b/Cargo.lock index c8bf13bf..96c39fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,11 +2,40 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "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" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" dependencies = [ "memchr", ] @@ -22,9 +51,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.64" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9a8f622bcf6ff3df478e9deba3e03e4e04b300f8e6a139e192c05fa3490afc7" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "arc-swap" @@ -32,6 +61,15 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "983cd8b9d4b02a6dc6ffa557262eb5858a27a0038ffffe21a0f133eaa819a164" +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -55,11 +93,32 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "bstr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca0852af221f458706eb0725c03e4ed6c46af9ac98e6a689d5e634215d594dd" +dependencies = [ + "memchr", + "once_cell", + "regex-automata", + "serde", +] + +[[package]] +name = "btoi" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97c0869a9faa81f8bbf8102371105d6d0a7b79167a04c340b04ab16892246a11" +dependencies = [ + "num-traits", +] + [[package]] name = "bumpalo" -version = "3.11.0" +version = "3.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d" +checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" [[package]] name = "bytecount" @@ -69,9 +128,15 @@ checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" [[package]] name = "bytes" -version = "1.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" +checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" + +[[package]] +name = "bytesize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c58ec36aac5066d5ca17df51b3e70279f5670a72102f5752cb7e7c856adfc70" [[package]] name = "cassowary" @@ -79,11 +144,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" -version = "1.0.73" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" [[package]] name = "cfg-if" @@ -104,9 +178,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", @@ -125,6 +199,33 @@ dependencies = [ "winapi", ] +[[package]] +name = "clru" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8191fa7302e03607ff0e237d4246cc043ff5b3cb9409d995172ba3bea16b807" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "compact_str" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5138945395949e7dfba09646dc9e766b548ff48e23deb5246890e6b64ae9e1b9" +dependencies = [ + "castaway", + "itoa", + "ryu", +] + [[package]] name = "content_inspector" version = "0.2.4" @@ -140,14 +241,22 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" -version = "0.8.11" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc" +checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" dependencies = [ "cfg-if", - "once_cell", ] [[package]] @@ -161,7 +270,7 @@ dependencies = [ "futures-core", "libc", "mio", - "parking_lot", + "parking_lot 0.12.1", "signal-hook", "signal-hook-mio", "winapi", @@ -176,6 +285,72 @@ dependencies = [ "winapi", ] +[[package]] +name = "cxx" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a41a86530d0fe7f5d9ea779916b7cadd2d4f9add748b99c2c029cbbdfaf453" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06416d667ff3e3ad2df1cd8cd8afae5da26cf9cec4d0825040f88b5ca659a2f0" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "820a9a2af1669deeef27cb271f476ffd196a2c4b6731336011e0ba63e2c7cf71" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a08a6e2fcc370a089ad3b4aaf54db3b1b4cee38ddabce5896b33eb693275f470" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" +dependencies = [ + "cfg-if", + "hashbrown 0.12.3", + "lock_api", + "once_cell", + "parking_lot_core 0.9.4", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -186,6 +361,17 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -260,6 +446,28 @@ dependencies = [ "log", ] +[[package]] +name = "filetime" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9663d381d07ae25dc88dbdf27df458faa83a9b25336bcac83d5e452b5fc9d3" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -277,58 +485,560 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.24" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" + +[[package]] +name = "futures-executor" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-task" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" + +[[package]] +name = "futures-util" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "git-actor" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7def29b46f25f95a2e196323cfb336eae9965e0a3c7c35ad9506f295c3a8e234" +dependencies = [ + "bstr 1.0.1", + "btoi", + "git-date", + "itoa", + "nom", + "quick-error", +] + +[[package]] +name = "git-attributes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0affaed361598fdd06b2a184a566c823d0b5817b09f576018248fb267193a96" +dependencies = [ + "bstr 1.0.1", + "compact_str", + "git-features", + "git-glob", + "git-path", + "git-quote", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "git-bitmap" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44304093ac66a0ada1b243c15c3a503a165a1d0f50bec748f4e5a9b84a0d0722" +dependencies = [ + "quick-error", +] + +[[package]] +name = "git-chunk" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3090baa2f4a3fe488a9b3e31090b83259aaf930bf0634af34c18117274f8f1a8" +dependencies = [ + "thiserror", +] + +[[package]] +name = "git-command" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b98a6312fef79b326c0a6e15d576c2bd30f7f9d0b7964998d166049e0d7b9e" +dependencies = [ + "bstr 1.0.1", +] + +[[package]] +name = "git-config" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ff189268cfb19d5151529ac30b6b708072ebfa1075643d785232675456ec320" +dependencies = [ + "bstr 1.0.1", + "git-config-value", + "git-features", + "git-glob", + "git-path", + "git-ref", + "git-sec", + "memchr", + "nom", + "once_cell", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "git-config-value" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" +checksum = "989a90c1c630513a153c685b4249b96fdf938afc75bf7ef2ae1ccbd3d799f5db" +dependencies = [ + "bitflags", + "bstr 1.0.1", + "git-path", + "libc", + "thiserror", +] + +[[package]] +name = "git-credentials" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da3d029be10258007699d002321a3b1ebe45e67b0e140a4cf464ba3ee79b32" +dependencies = [ + "bstr 1.0.1", + "git-command", + "git-config-value", + "git-path", + "git-prompt", + "git-sec", + "git-url", + "thiserror", +] + +[[package]] +name = "git-date" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2874ce2f3a77cb144167901ea830969e5c991eac7bfee85e6e3f53ef9fcdf2" +dependencies = [ + "bstr 1.0.1", + "itoa", + "thiserror", + "time", +] + +[[package]] +name = "git-diff" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f30011a43908645c492dfbea7b004e10528be6bd667bf5cdc12ff4297fe1e3c" +dependencies = [ + "git-hash", + "git-object", + "imara-diff", + "thiserror", +] + +[[package]] +name = "git-discover" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c244b1cf7cf45501116e948506c25324e33ddc613f00557ff5bfded2132009" +dependencies = [ + "bstr 1.0.1", + "git-hash", + "git-path", + "git-ref", + "git-sec", + "thiserror", +] + +[[package]] +name = "git-features" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f98e6ede7b790dfba16bf3c62861ae75c3719485d675b522cf7d7e748a4011c" +dependencies = [ + "crc32fast", + "flate2", + "git-hash", + "libc", + "once_cell", + "prodash", + "quick-error", + "sha1_smol", + "walkdir", +] + +[[package]] +name = "git-glob" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3908404c9b76ac7b3f636a104142378d3eaa78623cbc6eb7c7f0651979d48e8a" +dependencies = [ + "bitflags", + "bstr 1.0.1", +] + +[[package]] +name = "git-hash" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1532d82bf830532f8d545c5b7b568e311e3593f16cf7ee9dd0ce03c74b12b99d" +dependencies = [ + "hex", + "thiserror", +] + +[[package]] +name = "git-hashtable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52b625ad8cc360a0b7f426266f21fb07bd49b8f4ccf1b3ca7bc89424db1dec4" +dependencies = [ + "git-hash", + "hashbrown 0.13.1", +] + +[[package]] +name = "git-index" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20627f71f3a884b0ae50f9f3abb3a07d9b117d06e16110d25b85da4d71d478c0" +dependencies = [ + "atoi", + "bitflags", + "bstr 1.0.1", + "filetime", + "git-bitmap", + "git-features", + "git-hash", + "git-lock", + "git-object", + "git-traverse", + "itoa", + "memmap2", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-lock" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e4f05b8a68c3a5dd83a6651c76be384e910fe283072184fdab9d77f87ccec2" +dependencies = [ + "fastrand", + "git-tempfile", + "quick-error", +] + +[[package]] +name = "git-mailmap" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90e3ee2eaeebda8a12d17f4d99dff5b19d81536476020bcebb99ee121820466" +dependencies = [ + "bstr 1.0.1", + "git-actor", + "quick-error", +] + +[[package]] +name = "git-object" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b658f1e3e149d88cb3e0a2234be749bb0cab65887405975dbe6f3190cf6571" +dependencies = [ + "bstr 1.0.1", + "btoi", + "git-actor", + "git-features", + "git-hash", + "git-validate", + "hex", + "itoa", + "nom", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-odb" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55333419bbb25aa6d39e29155f747ad8e1777fe385f70f447be9d680824d23dd" +dependencies = [ + "arc-swap", + "git-features", + "git-hash", + "git-object", + "git-pack", + "git-path", + "git-quote", + "parking_lot 0.12.1", + "tempfile", + "thiserror", +] + +[[package]] +name = "git-pack" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed3c9af66949553af9795b9eac9d450a5bdceee9959352cda468997ddce0d2f" +dependencies = [ + "bytesize", + "clru", + "dashmap", + "git-chunk", + "git-diff", + "git-features", + "git-hash", + "git-hashtable", + "git-object", + "git-path", + "git-tempfile", + "git-traverse", + "memmap2", + "parking_lot 0.12.1", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-path" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40e68481a06da243d3f4dfd86a4be39c24eefb535017a862e845140dcdb878a" +dependencies = [ + "bstr 1.0.1", + "thiserror", +] + +[[package]] +name = "git-prompt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3612a486e507dd431ef0f7108eeaafc8fd1ed7bd0f205a88554f6f91fe5dccbf" +dependencies = [ + "git-command", + "git-config-value", + "nix", + "parking_lot 0.12.1", + "thiserror", +] + +[[package]] +name = "git-quote" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd11f4e7f251ab297545faa4c5a4517f4985a43b9c16bf96fa49107f58e837f" +dependencies = [ + "bstr 1.0.1", + "btoi", + "quick-error", +] + +[[package]] +name = "git-ref" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c97b7d719e4320179fb64d081016e7faca56fed4a8ee4cf84e4697faad9235a3" +dependencies = [ + "git-actor", + "git-features", + "git-hash", + "git-lock", + "git-object", + "git-path", + "git-tempfile", + "git-validate", + "memmap2", + "nom", + "thiserror", +] + +[[package]] +name = "git-refspec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d478e9db0956d60cd386d3348b5ec093e3ae613105a7a75ff6084b886254eba8" +dependencies = [ + "bstr 1.0.1", + "git-hash", + "git-revision", + "git-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-repository" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1925a65a9fea6587e969a7a85cb239c8e1e438cf6dc520406df1b4c9d0e83bdc" +dependencies = [ + "git-actor", + "git-attributes", + "git-config", + "git-credentials", + "git-date", + "git-diff", + "git-discover", + "git-features", + "git-glob", + "git-hash", + "git-hashtable", + "git-index", + "git-lock", + "git-mailmap", + "git-object", + "git-odb", + "git-pack", + "git-path", + "git-prompt", + "git-ref", + "git-refspec", + "git-revision", + "git-sec", + "git-tempfile", + "git-traverse", + "git-url", + "git-validate", + "git-worktree", + "log", + "once_cell", + "prodash", + "signal-hook", + "smallvec", + "thiserror", + "unicode-normalization", +] + +[[package]] +name = "git-revision" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7516b1db551756b4d3176c4b7d18ccc4b79d35dcc5e74f768c90f5bb11bb6c9" +dependencies = [ + "bstr 1.0.1", + "git-date", + "git-hash", + "git-hashtable", + "git-object", + "thiserror", +] + +[[package]] +name = "git-sec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1802e8252fa223b0ad89a393aed461132174ced1e6842a41f56dc92a3fc14f" +dependencies = [ + "bitflags", + "dirs", + "git-path", + "libc", + "windows", +] + +[[package]] +name = "git-tempfile" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6bb4dee86c8cae5a078cfaac3b004ef99c31548ed86218f23a7ff9b4b74f3be" +dependencies = [ + "dashmap", + "libc", + "once_cell", + "signal-hook", + "signal-hook-registry", + "tempfile", +] [[package]] -name = "futures-executor" -version = "0.3.24" +name = "git-traverse" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab" +checksum = "5e5141dde56d0c4861193c760e01fb61c7e03a32d0840ba93a0ac1c597588d4d" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "git-hash", + "git-hashtable", + "git-object", + "thiserror", ] [[package]] -name = "futures-task" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" - -[[package]] -name = "futures-util" -version = "0.3.24" +name = "git-url" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90" +checksum = "8651924c9692a778f09141ca44d1bf2dada229fe9b240f1ff1bdecd9621a1a93" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "pin-utils", - "slab", + "bstr 1.0.1", + "git-features", + "git-path", + "home", + "thiserror", + "url", ] [[package]] -name = "fuzzy-matcher" -version = "0.3.7" +name = "git-validate" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +checksum = "0431cf9352c596dc7c8ec9066ee551ce54e63c86c3c767e5baf763f6019ff3c2" dependencies = [ - "thread_local", + "bstr 1.0.1", + "thiserror", ] [[package]] -name = "getrandom" -version = "0.2.7" +name = "git-worktree" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +checksum = "17d748c54c3d904c914b987654a1416c7abe7cf048fdc83eeae69e6ac3d76f20" dependencies = [ - "cfg-if", - "libc", - "wasi", + "bstr 1.0.1", + "git-attributes", + "git-features", + "git-glob", + "git-hash", + "git-index", + "git-object", + "git-path", + "io-close", + "thiserror", ] [[package]] @@ -338,7 +1048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a1e17342619edbc21a964c2afbeb6c820c6a2560032872f397bb97ea127bd0a" dependencies = [ "aho-corasick", - "bstr", + "bstr 0.2.17", "fnv", "log", "regex", @@ -360,7 +1070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1345f8d33c89f2d5b081f2f2a41175adef9fd0bed2fea6a26c96c2deb027e58e" dependencies = [ "aho-corasick", - "bstr", + "bstr 0.2.17", "grep-matcher", "log", "regex", @@ -374,7 +1084,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48852bd08f9b4eb3040ecb6d2f4ade224afe880a9a0909c5563cc59fa67932cc" dependencies = [ - "bstr", + "bstr 0.2.17", "bytecount", "encoding_rs", "encoding_rs_io", @@ -383,15 +1093,37 @@ dependencies = [ "memmap2", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "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", + "imara-diff", "log", "once_cell", "quickcheck", @@ -399,7 +1131,6 @@ dependencies = [ "ropey", "serde", "serde_json", - "similar", "slotmap", "smallvec", "smartstring", @@ -450,6 +1181,7 @@ dependencies = [ "futures-executor", "futures-util", "helix-core", + "helix-loader", "log", "lsp-types", "serde", @@ -479,13 +1211,13 @@ dependencies = [ "helix-loader", "helix-lsp", "helix-tui", + "helix-vcs", "helix-view", "ignore", "indoc", "log", "once_cell", "pulldown-cmark", - "retain_mut", "serde", "serde_json", "signal-hook", @@ -508,9 +1240,23 @@ dependencies = [ "helix-core", "helix-view", "serde", + "termini", "unicode-segmentation", ] +[[package]] +name = "helix-vcs" +version = "0.6.0" +dependencies = [ + "git-repository", + "helix-core", + "imara-diff", + "log", + "parking_lot 0.12.1", + "tempfile", + "tokio", +] + [[package]] name = "helix-view" version = "0.6.0" @@ -524,8 +1270,10 @@ dependencies = [ "futures-util", "helix-core", "helix-dap", + "helix-loader", "helix-lsp", "helix-tui", + "helix-vcs", "log", "once_cell", "serde", @@ -547,20 +1295,51 @@ dependencies = [ "libc", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747309b4b440c06d57b0b25f2aee03ee9b5e5397d288c60e21fc709bb98a7408" +dependencies = [ + "winapi", +] + +[[package]] +name = "human_format" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86cce260d758a9aa3d7c4b99d55c815a540f8a37514ba6046ab6be402a157cb0" + [[package]] name = "iana-time-zone" -version = "0.1.47" +version = "0.1.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c495f162af0bf17656d0014a0eded5f3cd2f365fdd204548c2869db89359dc7" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" dependencies = [ "android_system_properties", "core-foundation-sys", + "iana-time-zone-haiku", "js-sys", - "once_cell", "wasm-bindgen", "winapi", ] +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "idna" version = "0.3.0" @@ -589,11 +1368,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "imara-diff" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e98c1d0ad70fc91b8b9654b1f33db55e59579d3b3de2bffdced0fdb810570cb8" +dependencies = [ + "ahash 0.8.2", + "hashbrown 0.12.3", +] + [[package]] name = "indoc" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" +checksum = "da2d6f23ffea9d7e76c53eee25dfb67bcd8fde7f1198b0855350698c9f07c780" [[package]] name = "instant" @@ -604,17 +1393,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "itoa" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" +checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" [[package]] name = "js-sys" -version = "0.3.59" +version = "0.3.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" dependencies = [ "wasm-bindgen", ] @@ -627,25 +1426,34 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.132" +version = "0.2.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" [[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", ] +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + [[package]] name = "lock_api" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f80bf5aacaf25cbfc8210d1cfb718f2bf3b11c4c54e5afe36c236853a8ec390" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" dependencies = [ "autocfg", "scopeguard", @@ -662,9 +1470,9 @@ dependencies = [ [[package]] name = "lsp-types" -version = "0.93.1" +version = "0.93.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3bcfee315dde785ba887edb540b08765fd7df75a7d948844be6bf5712246734" +checksum = "9be6e9c7e2d18f651974370d7aff703f9513e0df6e464fd795660edc77e6ca51" dependencies = [ "bitflags", "serde", @@ -681,18 +1489,33 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "memmap2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95af15f345b17af2efc8ead6080fb8bc376f8cec1b35277b935637595fe77498" +checksum = "4b182332558b18d807c4ce1ca8ca983b34c3ee32765e47b3f0f69b90355cc1dc" dependencies = [ "libc", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + [[package]] name = "mio" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" dependencies = [ "libc", "log", @@ -700,6 +1523,28 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nix" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a58d1d356c6597d08cde02c2f09d785b09e28711837b1ed667dc652c08a694" +dependencies = [ + "bitflags", + "cfg-if", + "libc", + "static_assertions", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -721,19 +1566,39 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +checksum = "f6058e64324c71e02bc2b150e4f3bc8286db6c83092132ffa3f6b1eab0f9def5" dependencies = [ "hermit-abi", "libc", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" -version = "1.14.0" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" + +[[package]] +name = "parking_lot" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] [[package]] name = "parking_lot" @@ -742,14 +1607,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.4", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", ] [[package]] name = "parking_lot_core" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" dependencies = [ "cfg-if", "libc", @@ -778,13 +1657,25 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2b91fcc982d0d8ae5e9d477561c73e09c24c5c19bac4858e202f6f065a13e" +dependencies = [ + "bytesize", + "dashmap", + "human_format", + "parking_lot 0.11.2", +] + [[package]] name = "pulldown-cmark" version = "0.9.2" @@ -796,6 +1687,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quickcheck" version = "1.0.3" @@ -825,9 +1722,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] @@ -854,9 +1751,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", @@ -871,9 +1768,9 @@ checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" [[package]] name = "regex-syntax" -version = "0.6.27" +version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" [[package]] name = "remove_dir_all" @@ -884,22 +1781,22 @@ dependencies = [ "winapi", ] -[[package]] -name = "retain_mut" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" - [[package]] name = "ropey" -version = "1.5.0" +version = "1.5.1-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd22239fafefc42138ca5da064f3c17726a80d2379d817a3521240e78dd0064" +checksum = "917e62c0dee8926492dd13164b3cefaad2b0e03ab49f48c0d41635797a7409b3" dependencies = [ "smallvec", "str_indices", ] +[[package]] +name = "rustversion" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8" + [[package]] name = "ryu" version = "1.0.11" @@ -921,20 +1818,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + [[package]] name = "serde" -version = "1.0.144" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.144" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", @@ -943,9 +1846,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.85" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa", "ryu", @@ -963,6 +1866,12 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "signal-hook" version = "0.3.14" @@ -1005,12 +1914,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "similar" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ac7f900db32bf3fd12e0117dd3dc4da74bc52ebaac97f39668446d89694803" - [[package]] name = "slab" version = "0.4.7" @@ -1031,9 +1934,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "smartstring" @@ -1082,9 +1985,9 @@ checksum = "9d9199fa80c817e074620be84374a520062ebac833f358d74b37060ce4a0f2c0" [[package]] name = "syn" -version = "1.0.99" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" +checksum = "4ae548ec36cf198c0ef7710d3c230987c2d6d7bd98ad6edc0274462724c585ce" dependencies = [ "proc-macro2", "quote", @@ -1105,11 +2008,29 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termini" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0f7ecb9c2a380d2686a747e4fc574043712326e8d39fbd220ab3bd29768a12" +dependencies = [ + "dirs-next", +] + [[package]] name = "textwrap" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" dependencies = [ "smawk", "unicode-linebreak", @@ -1118,18 +2039,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.34" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c1b05ca9d106ba7d2e31a9dab4a64e7be2cce415321966ea3132c49a656e252" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.34" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8f2591983642de85c921015f3f070c665a197ed69e417af436115e3a1407487" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" dependencies = [ "proc-macro2", "quote", @@ -1154,6 +2075,35 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "time" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" +dependencies = [ + "itoa", + "libc", + "num_threads", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + +[[package]] +name = "time-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1171,9 +2121,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.21.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89797afd69d206ccd11fb0ea560a44bbb87731d020670e79416d442919257d42" +checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" dependencies = [ "autocfg", "bytes", @@ -1181,13 +2131,12 @@ dependencies = [ "memchr", "mio", "num_cpus", - "once_cell", - "parking_lot", + "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "winapi", + "windows-sys", ] [[package]] @@ -1203,9 +2152,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" +checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" dependencies = [ "futures-core", "pin-project-lite", @@ -1214,9 +2163,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +checksum = "1333c76748e868a4d9d1017b5ab53171dfd095f70c712fdb4653a406547f598f" dependencies = [ "serde", ] @@ -1246,47 +2195,54 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" +[[package]] +name = "unicode-bom" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63ec69f541d875b783ca40184d655f2927c95f0bffd486faa83cd3ac3529ec32" + [[package]] name = "unicode-general-category" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1218098468b8085b19a2824104c70d976491d247ce194bbd9dc77181150cdfd6" +checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7" [[package]] name = "unicode-ident" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" [[package]] name = "unicode-linebreak" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" +checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" dependencies = [ + "hashbrown 0.12.3", "regex", ] [[package]] name = "unicode-normalization" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" +checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" [[package]] name = "unicode-width" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" [[package]] name = "url" @@ -1325,9 +2281,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.82" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" +checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -1335,9 +2291,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.82" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" +checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" dependencies = [ "bumpalo", "log", @@ -1350,9 +2306,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.82" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" +checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1360,9 +2316,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.82" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" +checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" dependencies = [ "proc-macro2", "quote", @@ -1373,9 +2329,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.82" +version = "0.2.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" +checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" [[package]] name = "which" @@ -1419,48 +2375,119 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e30acc718a52fb130fec72b1cb5f55ffeeec9253e1b785e94db222178a6acaa1" +dependencies = [ + "windows_aarch64_gnullvm 0.40.0", + "windows_aarch64_msvc 0.40.0", + "windows_i686_gnu 0.40.0", + "windows_i686_msvc 0.40.0", + "windows_x86_64_gnu 0.40.0", + "windows_x86_64_gnullvm 0.40.0", + "windows_x86_64_msvc 0.40.0", +] + [[package]] name = "windows-sys" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.0", + "windows_aarch64_msvc 0.42.0", + "windows_i686_gnu 0.42.0", + "windows_i686_msvc 0.42.0", + "windows_x86_64_gnu 0.42.0", + "windows_x86_64_gnullvm 0.42.0", + "windows_x86_64_msvc 0.42.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3caa4a1a16561b714323ca6b0817403738583033a6a92e04c5d10d4ba37ca10" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328973c62dfcc50fb1aaa8e7100676e0b642fe56bac6bafff3327902db843ab4" + [[package]] name = "windows_aarch64_msvc" -version = "0.36.1" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" + +[[package]] +name = "windows_i686_gnu" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +checksum = "aa5b09fad70f0df85dea2ac2a525537e415e2bf63ee31cf9b8e263645ee9f3c1" [[package]] name = "windows_i686_gnu" -version = "0.36.1" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" + +[[package]] +name = "windows_i686_msvc" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +checksum = "2a1ad4031c1a98491fa195d8d43d7489cb749f135f2e5c4eed58da094bd0d876" [[package]] name = "windows_i686_msvc" -version = "0.36.1" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +checksum = "520ff37edd72da8064b49d2281182898e17f0688ae9f4070bca27e4b5c162ac7" [[package]] name = "windows_x86_64_gnu" -version = "0.36.1" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046e5b82215102c44fd75f488f1b9158973d02aa34d06ed85c23d6f5520a2853" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +checksum = "2a0c9c6df55dd1bfa76e131cef44bdd8ec9c819ef3611f04dfe453fd5bfeda28" [[package]] name = "windows_x86_64_msvc" -version = "0.36.1" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" [[package]] name = "xtask" diff --git a/Cargo.toml b/Cargo.toml index 780811f7..ecf6848e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "helix-lsp", "helix-dap", "helix-loader", + "helix-vcs", "xtask", ] @@ -14,9 +15,6 @@ default-members = [ "helix-term" ] -[profile.dev] -split-debuginfo = "unpacked" - [profile.release] lto = "thin" # debug = true diff --git a/README.md b/README.md index ff0699c6..b06e8222 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ -# Helix +
+ +

+ + + + Helix + +

[![Build status](https://github.com/helix-editor/helix/actions/workflows/build.yml/badge.svg)](https://github.com/helix-editor/helix/actions) +[![GitHub Release](https://img.shields.io/github/v/release/helix-editor/helix)](https://github.com/helix-editor/helix/releases/latest) +[![Documentation](https://shields.io/badge/-documentation-452859)](https://docs.helix-editor.com/) +[![GitHub contributors](https://img.shields.io/github/contributors/helix-editor/helix)](https://github.com/helix-editor/helix/graphs/contributors) +[![Matrix Space](https://img.shields.io/matrix/helix-community:matrix.org)](https://matrix.to/#/#helix-community:matrix.org) + +
![Screenshot](./screenshot.png) @@ -41,20 +55,41 @@ cd helix cargo install --path helix-term ``` -This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars. -If you want to customize your `languages.toml` config, -tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`. +This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`. -Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the +Helix needs its runtime files so make sure to copy/symlink the `runtime/` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows). -| OS | Command | -| -------------------- | -------------------------------------------- | -| Windows (cmd.exe) | `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` | +| OS | Command | +| -------------------- | ------------------------------------------------ | +| 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` | + +Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires +elevated privileges - i.e. PowerShell or Cmd must be run as administrator. + +**PowerShell:** + +```powershell +New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime" +``` + +**Cmd:** + +```cmd +cd %appdata%\helix +mklink /D runtime "\runtime" +``` + +The runtime location can be overridden via the `HELIX_RUNTIME` environment variable. + +> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term`, +> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`. -This location can be overridden via the `HELIX_RUNTIME` environment variable. +If you plan on keeping the repo locally, an alternative to copying/symlinking +runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime` +(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory). Packages already solve this for you by wrapping the `hx` binary with a wrapper that sets the variable to the install dir. @@ -62,15 +97,35 @@ that sets the variable to the install dir. > NOTE: running via cargo also doesn't require setting explicit `HELIX_RUNTIME` path, it will automatically > detect the `runtime` directory in the project root. +If you want to customize your `languages.toml` config, +tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`. + In order to use LSP features like auto-complete, you will need to [install the appropriate Language Server](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers) for a language. [![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) -## MacOS +## Adding Helix to your desktop environment -Helix can be installed on MacOS through homebrew: +If installing from source, to use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder: + +```bash +cp contrib/Helix.desktop ~/.local/share/applications +``` + +To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`: + +```bash +sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop +sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop +``` + +Please note: there is no icon for Helix yet, so the system default will be used. + +## macOS + +Helix can be installed on macOS through homebrew: ``` brew install helix @@ -85,3 +140,7 @@ Contributing guidelines can be found [here](./docs/CONTRIBUTING.md). Your question might already be answered on the [FAQ](https://github.com/helix-editor/helix/wiki/FAQ). Discuss the project on the community [Matrix Space](https://matrix.to/#/#helix-community:matrix.org) (make sure to join `#helix-editor:matrix.org` if you're on a client that doesn't support Matrix Spaces yet). + +# Credits + +Thanks to [@JakeHL](https://github.com/JakeHL) for designing the logo! diff --git a/VERSION b/VERSION index b9ed4c22..e70b3aeb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -22.08.1 \ No newline at end of file +22.12 \ No newline at end of file diff --git a/base16_theme.toml b/base16_theme.toml index 63fc2f79..268a38df 100644 --- a/base16_theme.toml +++ b/base16_theme.toml @@ -7,6 +7,7 @@ "ui.linenr.selected" = { fg = "white", bg = "black", modifiers = ["bold"] } "ui.selection" = { fg = "black", bg = "blue" } "ui.selection.primary" = { fg = "white", bg = "blue" } +"ui.text.inactive" = { fg = "gray" } "comment" = { fg = "gray" } "ui.statusline" = { fg = "black", bg = "white" } "ui.statusline.inactive" = { fg = "gray", bg = "white" } diff --git a/book/book.toml b/book/book.toml index 2277a0bd..9835145c 100644 --- a/book/book.toml +++ b/book/book.toml @@ -9,3 +9,4 @@ edit-url-template = "https://github.com/helix-editor/helix/tree/master/book/{pat cname = "docs.helix-editor.com" default-theme = "colibri" preferred-dark-theme = "colibri" +git-repository-url = "https://github.com/helix-editor/helix" diff --git a/book/src/configuration.md b/book/src/configuration.md index fdabe768..0890d283 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -28,22 +28,28 @@ hidden = false You may also specify a file to use for configuration with the `-c` or `--config` CLI argument: `hx -c path/to/custom-config.toml`. +It is also possible to trigger configuration file reloading by sending the `USR1` +signal to the helix process, e.g. via `pkill -USR1 hx`. This is only supported +on unix operating systems. + ## Editor ### `[editor]` Section | Key | Description | Default | |--|--|---------| -| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling. | `3` | +| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling. | `5` | | `mouse` | Enable mouse mode. | `true` | | `middle-click-paste` | Middle click paste support. | `true` | | `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` | | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | | `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` | -| `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"]` | +| `cursorcolumn` | Highlight all columns with a cursor. | `false` | +| `gutters` | Gutters to display: Available are `diagnostics` and `diff` 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", "spacer", "diff"]` | | `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` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `auto-info` | Whether to display infoboxes | `true` | @@ -68,20 +74,37 @@ left = ["mode", "spinner"] center = ["file-name"] right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"] separator = "│" +mode.normal = "NORMAL" +mode.insert = "INSERT" +mode.select = "SELECT" ``` +The `[editor.statusline]` key takes the following sub-keys: + +| Key | Description | Default | +| --- | --- | --- | +| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name"]` | +| `center` | A list of elements aligned to the middle of the statusline | `[]` | +| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "position", "file-encoding"]` | +| `separator` | The character used to separate elements in the statusline | `"│"` | +| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` | +| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` | +| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` | -The following elements can be configured: +The following statusline elements can be configured: | Key | Description | | ------ | ----------- | -| `mode` | The current editor mode (`NOR`/`INS`/`SEL`) | +| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) | | `spinner` | A progress spinner indicating LSP activity | | `file-name` | The path/name of the opened file | | `file-encoding` | The encoding of the opened file if it differs from UTF-8 | | `file-line-ending` | The file line endings (CRLF or LF) | +| `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 `"│"`) | @@ -218,15 +241,17 @@ tabpad = "·" # Tabs will look like "→···" (depending on tab width) Options for rendering vertical indent guides. -| Key | Description | Default | -| --- | --- | --- | -| `render` | Whether to render indent guides. | `false` | -| `character` | Literal character to use for rendering the indent guide | `│` | +| Key | Description | Default | +| --- | --- | --- | +| `render` | Whether to render indent guides. | `false` | +| `character` | Literal character to use for rendering the indent guide | `│` | +| `skip-levels` | Number of indent levels to skip | `0` | Example: ```toml [editor.indent-guides] render = true -character = "╎" +character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽" +skip-levels = 1 ``` diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 86c26042..1a3aed79 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -2,19 +2,24 @@ | --- | --- | --- | --- | --- | | astro | ✓ | | | | | awk | ✓ | ✓ | | `awk-language-server` | -| bash | ✓ | | | `bash-language-server` | +| bash | ✓ | | ✓ | `bash-language-server` | | bass | ✓ | | | `bass` | | beancount | ✓ | | | | +| bibtex | ✓ | | | `texlab` | +| bicep | ✓ | | | `bicep-langserver` | | c | ✓ | ✓ | ✓ | `clangd` | | c-sharp | ✓ | ✓ | | `OmniSharp` | | cairo | ✓ | | | | | clojure | ✓ | | | `clojure-lsp` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` | | comment | ✓ | | | | +| common-lisp | ✓ | | | `cl-lsp` | | cpon | ✓ | | ✓ | | | cpp | ✓ | ✓ | ✓ | `clangd` | +| crystal | ✓ | | | | | css | ✓ | | | `vscode-css-language-server` | | cue | ✓ | | | `cuelsp` | +| d | ✓ | ✓ | ✓ | `serve-d` | | dart | ✓ | | ✓ | `dart` | | devicetree | ✓ | | | | | diff | ✓ | | | | @@ -23,9 +28,10 @@ | edoc | ✓ | | | | | eex | ✓ | | | | | ejs | ✓ | | | | -| elixir | ✓ | ✓ | | `elixir-ls` | +| elixir | ✓ | ✓ | ✓ | `elixir-ls` | | elm | ✓ | | | `elm-language-server` | | elvish | ✓ | | | `elvish` | +| env | ✓ | | | | | erb | ✓ | | | | | erlang | ✓ | ✓ | | `erlang_ls` | | esdl | ✓ | | | | @@ -46,19 +52,21 @@ | gowork | ✓ | | | `gopls` | | graphql | ✓ | | | | | hare | ✓ | | | | -| haskell | ✓ | | | `haskell-language-server-wrapper` | +| haskell | ✓ | ✓ | | `haskell-language-server-wrapper` | | hcl | ✓ | | ✓ | `terraform-ls` | -| heex | ✓ | ✓ | | | +| heex | ✓ | ✓ | | `elixir-ls` | | html | ✓ | | | `vscode-html-language-server` | | idris | | | | `idris2-lsp` | | iex | ✓ | | | | -| java | ✓ | | | `jdtls` | +| ini | ✓ | | | | +| java | ✓ | ✓ | | `jdtls` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | jsdoc | ✓ | | | | | json | ✓ | | ✓ | `vscode-json-language-server` | | jsonnet | ✓ | | | `jsonnet-language-server` | | jsx | ✓ | ✓ | ✓ | `typescript-language-server` | | julia | ✓ | | | `julia` | +| kdl | ✓ | | | | | kotlin | ✓ | | | `kotlin-language-server` | | latex | ✓ | ✓ | | `texlab` | | lean | ✓ | | | `lean` | @@ -66,14 +74,16 @@ | llvm | ✓ | ✓ | ✓ | | | llvm-mir | ✓ | ✓ | ✓ | | | llvm-mir-yaml | ✓ | | ✓ | | -| lua | ✓ | | ✓ | `lua-language-server` | +| lua | ✓ | ✓ | ✓ | `lua-language-server` | | make | ✓ | | | | | markdown | ✓ | | | `marksman` | | markdown.inline | ✓ | | | | +| matlab | ✓ | | | | +| mermaid | ✓ | | | | | meson | ✓ | | ✓ | | | mint | | | | `mint` | | nickel | ✓ | | ✓ | `nls` | -| nix | ✓ | | | `rnix-lsp` | +| nix | ✓ | | | `nil` | | nu | ✓ | | | | | ocaml | ✓ | | ✓ | `ocamllsp` | | ocaml-interface | ✓ | | | `ocamllsp` | @@ -86,9 +96,11 @@ | prisma | ✓ | | | `prisma-language-server` | | prolog | | | | `swipl` | | protobuf | ✓ | | ✓ | | -| python | ✓ | ✓ | | `pylsp` | +| purescript | ✓ | | | `purescript-language-server` | +| python | ✓ | ✓ | ✓ | `pylsp` | +| qml | ✓ | | ✓ | `qmlls` | | r | ✓ | | | `R` | -| racket | | | | `racket` | +| racket | ✓ | | | `racket` | | regex | ✓ | | | | | rescript | ✓ | ✓ | | `rescript-language-server` | | rmarkdown | ✓ | | ✓ | `R` | @@ -118,8 +130,13 @@ | v | ✓ | | | `vls` | | vala | ✓ | | | `vala-language-server` | | verilog | ✓ | ✓ | | `svlangserver` | +| vhs | ✓ | | | | | vue | ✓ | | | `vls` | +| wast | ✓ | | | | +| wat | ✓ | | | | | wgsl | ✓ | | | `wgsl_analyzer` | +| wit | ✓ | | ✓ | | | xit | ✓ | | | | +| xml | ✓ | | ✓ | | | yaml | ✓ | | ✓ | `yaml-language-server` | -| zig | ✓ | | ✓ | `zls` | +| zig | ✓ | ✓ | ✓ | `zls` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 653acf60..66e6ac03 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -28,7 +28,7 @@ | `:quit-all!`, `:qa!` | Force close all views ignoring unsaved changes. | | `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). | | `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). | -| `:theme` | Change the editor theme. | +| `:theme` | Change the editor theme (show current theme if no name specified). | | `:clipboard-yank` | Yank main selection into system clipboard. | | `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. | | `:primary-clipboard-yank` | Yank main selection into system primary clipboard. | @@ -44,6 +44,10 @@ | `: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. | | `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. | @@ -64,7 +68,8 @@ | `:config-reload` | Refresh user config. | | `:config-open` | Open the user config.toml file. | | `:log-open` | Open the helix log file. | -| `:insert-output` | Run shell command, inserting output after each selection. | +| `:insert-output` | Run shell command, inserting output before each selection. | | `:append-output` | Run shell command, appending output after each selection. | | `:pipe` | Pipe each selection to the shell command. | +| `:pipe-to` | Pipe each selection to the shell command, ignoring output. | | `:run-shell-command`, `:sh` | Run a shell command | diff --git a/book/src/guides/indent.md b/book/src/guides/indent.md index f4d916b2..0e259289 100644 --- a/book/src/guides/indent.md +++ b/book/src/guides/indent.md @@ -46,6 +46,20 @@ capture on the same line, the indent level isn't changed at all. - `@outdent` (default scope `all`): Decrease the indent level by 1. The same rules as for `@indent` apply. +- `@extend`: +Extend the range of this node to the end of the line and to lines that +are indented more than the line that this node starts on. This is useful +for languages like Python, where for the purpose of indentation some nodes +(like functions or classes) should also contain indented lines that follow them. + +- `@extend.prevent-once`: +Prevents the first extension of an ancestor of this node. For example, in Python +a return expression always ends the block that it is in. Note that this only stops the +extension of the next `@extend` capture. If multiple ancestors are captured, +only the extension of the innermost one is prevented. All other ancestors are unaffected +(regardless of whether the innermost ancestor would actually have been extended). + + ## Predicates In some cases, an S-expression cannot express exactly what pattern should be matched. diff --git a/book/src/install.md b/book/src/install.md index d7a51ac2..44f13584 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -50,6 +50,41 @@ sudo dnf install helix sudo xbps-install helix ``` +## Windows + +Helix can be installed using [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/) +or [MSYS2](https://msys2.org/). + +**Scoop:** + +``` +scoop install helix +``` + +**Chocolatey:** + +``` +choco install helix +``` + +**MSYS2:** + +``` +pacman -S mingw-w64-i686-helix +``` + +or + +``` +pacman -S mingw-w64-x86_64-helix +``` + +or + +``` +pacman -S mingw-w64-ucrt-x86_64-helix +``` + ## Build from source ``` @@ -58,26 +93,67 @@ cd helix cargo install --path helix-term ``` -This will install the `hx` binary to `$HOME/.cargo/bin`. +This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`. -Helix also needs it's runtime files so make sure to copy/symlink the `runtime/` directory into the +Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden via the `HELIX_RUNTIME` environment variable. -| OS | command | -|-------------------|-----------| -|windows(cmd.exe) |`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`| +| OS | Command | +| -------------------- | ------------------------------------------------ | +| 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` | + +Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires +elevated privileges - i.e. PowerShell or Cmd must be run as administrator. + +**PowerShell:** + +```powershell +New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime" +``` + +**Cmd:** + +```cmd +cd %appdata%\helix +mklink /D runtime "\runtime" +``` + +The runtime location can be overridden via the `HELIX_RUNTIME` environment variable. + +> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term`, +> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`. + +If you plan on keeping the repo locally, an alternative to copying/symlinking +runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime` +(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory). + +To use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder: + +```bash +cp contrib/Helix.desktop ~/.local/share/applications +``` + +To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`: + +```bash +sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop +sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop +``` + +Please note: there is no icon for Helix yet, so the system default will be used. + +## Finishing up the installation -## Finishing up the installation +To make sure everything is set up as expected you should finally run the helix healthcheck via -To make sure everything is set up as expected you should finally run the helix healthcheck via ``` hx --health ``` -For more information on the information displayed in the healthcheck results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck). +For more information on the information displayed in the health check results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck). ### Building tree-sitter grammars diff --git a/book/src/keymap.md b/book/src/keymap.md index 99b0f0ba..272c758b 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -68,8 +68,8 @@ | `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` | | `i` | Insert before selection | `insert_mode` | | `a` | Insert after selection (append) | `append_mode` | -| `I` | Insert at the start of the line | `prepend_to_line` | -| `A` | Insert at the end of the line | `append_to_line` | +| `I` | Insert at the start of the line | `insert_at_line_start` | +| `A` | Insert at the end of the line | `insert_at_line_end` | | `o` | Open new line below selection | `open_below` | | `O` | Open new line above selection | `open_above` | | `.` | Repeat last insert | N/A | @@ -111,6 +111,7 @@ | `s` | Select all regex matches inside selections | `select_regex` | | `S` | Split selection into subselections on regex matches | `split_selection` | | `Alt-s` | Split selection on newlines | `split_selection_on_newline` | +| `Alt-_ ` | Merge consecutive selections | `merge_consecutive_selections` | | `&` | Align selection in columns | `align_selections` | | `_` | Trim whitespace from the selection | `trim_selections` | | `;` | Collapse selection onto a single cursor | `collapse_selection` | @@ -129,6 +130,7 @@ | `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | | `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` | | `J` | Join lines inside selection | `join_selections` | +| `Alt-J` | Join lines inside selection and select space | `join_selections_space` | | `K` | Keep selections matching the regex | `keep_selections` | | `Alt-K` | Remove selections matching the regex | `remove_selections` | | `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | @@ -166,10 +168,13 @@ These sub-modes are accessible from normal mode and typically switch back to nor #### View mode +Accessed by typing `z` in [normal mode](#normal-mode). + View mode is intended for scrolling and manipulating the view without changing -the selection. The "sticky" variant of this mode is persistent; use the Escape -key to return to normal mode after usage (useful when you're simply looking -over text and not actively editing it). +the selection. The "sticky" variant of this mode (accessed by typing `Z` in +normal mode) is persistent; use the Escape key to return to normal mode after +usage (useful when you're simply looking over text and not actively editing +it). | Key | Description | Command | @@ -187,6 +192,8 @@ over text and not actively editing it). #### Goto mode +Accessed by typing `g` in [normal mode](#normal-mode). + Jumps to various locations. | Key | Description | Command | @@ -212,9 +219,10 @@ Jumps to various locations. #### Match mode -Enter this mode using `m` from normal mode. See the relevant section -in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround) -and [textobject](./usage.md#textobject) usage. +Accessed by typing `m` in [normal mode](#normal-mode). + +See the relevant section in [Usage](./usage.md) for an explanation about +[surround](./usage.md#surround) and [textobject](./usage.md#textobjects) usage. | Key | Description | Command | | ----- | ----------- | ------- | @@ -229,6 +237,8 @@ TODO: Mappings for selecting syntax nodes (a superset of `[`). #### Window mode +Accessed by typing `Ctrl-w` in [normal mode](#normal-mode). + This layer is similar to Vim keybindings as Kakoune does not support window. | Key | Description | Command | @@ -251,8 +261,9 @@ This layer is similar to Vim keybindings as Kakoune does not support window. #### Space mode -This layer is a kludge of mappings, mostly pickers. +Accessed by typing `Space` in [normal mode](#normal-mode). +This layer is a kludge of mappings, mostly pickers. | Key | Description | Command | | ----- | ----------- | ------- | @@ -263,8 +274,8 @@ This layer is a kludge of mappings, mostly pickers. | `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` | | `s` | Open document symbol picker (**LSP**) | `symbol_picker` | | `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` | -| `g` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` | -| `G` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` +| `d` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` | +| `D` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` | | `r` | Rename symbol (**LSP**) | `rename_symbol` | | `a` | Apply code action (**LSP**) | `code_action` | | `'` | Open last fuzzy picker | `last_picker` | @@ -277,7 +288,7 @@ This layer is a kludge of mappings, mostly pickers. | `/` | Global search in workspace folder | `global_search` | | `?` | Open command palette | `command_palette` | -> TIP: Global search displays results in a fuzzy picker, use `space + '` to bring it back up after opening a file. +> TIP: Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file. ##### Popup @@ -287,7 +298,7 @@ Displays documentation for item under cursor. | ---- | ----------- | | `Ctrl-u` | Scroll up | | `Ctrl-d` | Scroll down | - + #### Unimpaired Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). @@ -300,20 +311,24 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire | `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | | `]f` | Go to next function (**TS**) | `goto_next_function` | | `[f` | Go to previous function (**TS**) | `goto_prev_function` | -| `]c` | Go to next class (**TS**) | `goto_next_class` | -| `[c` | Go to previous class (**TS**) | `goto_prev_class` | +| `]t` | Go to next type definition (**TS**) | `goto_next_class` | +| `[t` | Go to previous type definition (**TS**) | `goto_prev_class` | | `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` | | `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | -| `]o` | Go to next comment (**TS**) | `goto_next_comment` | -| `[o` | Go to previous comment (**TS**) | `goto_prev_comment` | -| `]t` | Go to next test (**TS**) | `goto_next_test` | -| `]t` | Go to previous test (**TS**) | `goto_prev_test` | +| `]c` | Go to next comment (**TS**) | `goto_next_comment` | +| `[c` | Go to previous comment (**TS**) | `goto_prev_comment` | +| `]T` | Go to next test (**TS**) | `goto_next_test` | +| `[T` | Go to previous test (**TS**) | `goto_prev_test` | | `]p` | Go to next paragraph | `goto_next_paragraph` | | `[p` | Go to previous paragraph | `goto_prev_paragraph` | -| `[space` | Add newline above | `add_newline_above` | -| `]space` | Add newline below | `add_newline_below` | +| `]g` | Go to next change | `goto_next_change` | +| `[g` | Go to previous change | `goto_prev_change` | +| `]G` | Go to first change | `goto_first_change` | +| `[G` | Go to last change | `goto_last_change` | +| `[Space` | Add newline above | `add_newline_above` | +| `]Space` | Add newline below | `add_newline_below` | -## Insert Mode +## Insert mode Insert mode bindings are somewhat minimal by default. Helix is designed to be a modal editor, and this is reflected in the user experience and internal @@ -322,44 +337,47 @@ escaping from insert mode to normal mode. For this reason, new users are strongly encouraged to learn the modal editing paradigm to get the smoothest experience. -| Key | Description | Command | -| ----- | ----------- | ------- | -| `Escape` | Switch to normal mode | `normal_mode` | -| `Ctrl-x` | Autocomplete | `completion` | -| `Ctrl-r` | Insert a register content | `insert_register` | -| `Ctrl-w`, `Alt-Backspace`, `Ctrl-Backspace` | Delete previous word | `delete_word_backward` | -| `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | `delete_word_forward` | -| `Ctrl-u` | Delete to start of line | `kill_to_line_start` | -| `Ctrl-k` | Delete to end of line | `kill_to_line_end` | -| `Ctrl-j`, `Enter` | Insert new line | `insert_newline` | -| `Backspace`, `Ctrl-h` | Delete previous char | `delete_char_backward` | -| `Delete`, `Ctrl-d` | Delete next char | `delete_char_forward` | - -However, if you really want navigation in insert mode, this is supported. An -example config that gives the ability to use arrow keys while still in insert -mode: +| Key | Description | Command | +| ----- | ----------- | ------- | +| `Escape` | Switch to normal mode | `normal_mode` | +| `Ctrl-s` | Commit undo checkpoint | `commit_undo_checkpoint` | +| `Ctrl-x` | Autocomplete | `completion` | +| `Ctrl-r` | Insert a register content | `insert_register` | +| `Ctrl-w`, `Alt-Backspace` | Delete previous word | `delete_word_backward` | +| `Alt-d`, `Alt-Delete` | Delete next word | `delete_word_forward` | +| `Ctrl-u` | Delete to start of line | `kill_to_line_start` | +| `Ctrl-k` | Delete to end of line | `kill_to_line_end` | +| `Ctrl-h`, `Backspace` | Delete previous char | `delete_char_backward` | +| `Ctrl-d`, `Delete` | Delete next char | `delete_char_forward` | +| `Ctrl-j`, `Enter` | Insert new line | `insert_newline` | + +These keys are not recommended, but are included for new users less familiar +with modal editors. + +| Key | Description | Command | +| ----- | ----------- | ------- | +| `Up` | Move to previous line | `move_line_up` | +| `Down` | Move to next line | `move_line_down` | +| `Left` | Backward a char | `move_char_left` | +| `Right` | Forward a char | `move_char_right` | +| `PageUp` | Move one page up | `page_up` | +| `PageDown` | Move one page down | `page_down` | +| `Home` | Move to line start | `goto_line_start` | +| `End` | Move to line end | `goto_line_end_newline` | + +If you want to disable them in insert mode as you become more comfortable with modal editing, you can use +the following in your `config.toml`: ```toml [keys.insert] -"up" = "move_line_up" -"down" = "move_line_down" -"left" = "move_char_left" -"right" = "move_char_right" -"C-b" = "move_char_left" -"C-f" = "move_char_right" -"A-b" = "move_prev_word_end" -"C-left" = "move_prev_word_end" -"A-f" = "move_next_word_start" -"C-right" = "move_next_word_start" -"A-<" = "goto_file_start" -"A->" = "goto_file_end" -"pageup" = "page_up" -"pagedown" = "page_down" -"home" = "goto_line_start" -"C-a" = "goto_line_start" -"end" = "goto_line_end_newline" -"C-e" = "goto_line_end_newline" -"A-left" = "goto_line_start" +up = "no_op" +down = "no_op" +left = "no_op" +right = "no_op" +pageup = "no_op" +pagedown = "no_op" +home = "no_op" +end = "no_op" ``` ## Select / extend mode @@ -381,13 +399,12 @@ Keys to use within picker. Remapping currently not supported. | Key | Description | | ----- | ------------- | -| `Tab`, `Up`, `Ctrl-p` | Previous entry | +| `Shift-Tab`, `Up`, `Ctrl-p` | Previous entry | +| `Tab`, `Down`, `Ctrl-n` | Next entry | | `PageUp`, `Ctrl-u` | Page up | -| `Shift-tab`, `Down`, `Ctrl-n`| Next entry | | `PageDown`, `Ctrl-d` | Page down | | `Home` | Go to first entry | | `End` | Go to last entry | -| `Ctrl-space` | Filter options | | `Enter` | Open selected | | `Ctrl-s` | Open horizontally | | `Ctrl-v` | Open vertically | @@ -411,8 +428,8 @@ Keys to use within prompt, Remapping currently not supported. | `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | | `Ctrl-u` | Delete to start of line | | `Ctrl-k` | Delete to end of line | -| `backspace`, `Ctrl-h` | Delete previous char | -| `delete`, `Ctrl-d` | Delete next char | +| `Backspace`, `Ctrl-h` | Delete previous char | +| `Delete`, `Ctrl-d` | Delete next char | | `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later | | `Ctrl-p`, `Up` | Select previous history | | `Ctrl-n`, `Down` | Select next history | diff --git a/book/src/languages.md b/book/src/languages.md index 841b1377..e45ef910 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -39,7 +39,7 @@ injection-regex = "^mylang$" file-types = ["mylang", "myl"] comment-token = "#" indent = { tab-width = 2, unit = " " } -language-server = { command = "mylang-lsp", args = ["--stdio"] } +language-server = { command = "mylang-lsp", args = ["--stdio"], environment = { "ENV1" = "value1", "ENV2" = "value2" } } formatter = { command = "mylang-formatter" , args = ["--stdin"] } ``` @@ -50,7 +50,7 @@ These configuration keys are available: | `name` | The name of the language | | `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.` or `text.` in case of markup languages | | `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | -| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. Extensions and full file names are supported. | +| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. See the file-type detection section below. | | `shebangs` | The interpreters from the shebang line, for example `["sh", "bash"]` | | `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | | `auto-format` | Whether to autoformat this language when saving | @@ -61,6 +61,33 @@ These configuration keys are available: | `config` | Language Server configuration | | `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) | | `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout | +| `max-line-length` | Maximum line length. Used for the `:reflow` command | + +### File-type detection and the `file-types` key + +Helix determines which language configuration to use with the `file-types` key +from the above section. `file-types` is a list of strings or tables, for +example: + +```toml +file-types = ["Makefile", "toml", { suffix = ".git/config" }] +``` + +When determining a language configuration to use, Helix searches the file-types +with the following priorities: + +1. Exact match: if the filename of a file is an exact match of a string in a + `file-types` list, that language wins. In the example above, `"Makefile"` + will match against `Makefile` files. +2. Extension: if there are no exact matches, any `file-types` string that + matches the file extension of a given file wins. In the example above, the + `"toml"` matches files like `Cargo.toml` or `languages.toml`. +3. Suffix: if there are still no matches, any values in `suffix` tables + are checked against the full path of the given file. In the example above, + the `{ suffix = ".git/config" }` would match against any `config` files + in `.git` directories. Note: `/` is used as the directory separator but is + replaced at runtime with the appropriate path separator for the operating + system, so this rule would match against `.git\config` files on Windows. ### Language Server configuration @@ -72,6 +99,7 @@ The `language-server` field takes the following keys: | `args` | A list of arguments to pass to the language server binary | | `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | | `language-id` | The language name to pass to the language server. Some language servers support multiple languages and use this field to determine which one is being served in a buffer | +| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` | The top-level `config` field is used to configure the LSP initialization options. A `format` sub-table within `config` can be used to pass extra formatting options to diff --git a/book/src/remapping.md b/book/src/remapping.md index bd4ac7f8..e89c6611 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -11,11 +11,11 @@ this: ```toml # At most one section each of 'keys.normal', 'keys.insert' and 'keys.select' [keys.normal] -C-s = ":w" # Maps the Control-s to the typable command :w which is an alias for :write (save file) -C-o = ":open ~/.config/helix/config.toml" # Maps the Control-o to opening of the helix config file +C-s = ":w" # Maps the Ctrl-s to the typable command :w which is an alias for :write (save file) +C-o = ":open ~/.config/helix/config.toml" # Maps the Ctrl-o to opening of the helix config file a = "move_char_left" # Maps the 'a' key to the move_char_left command w = "move_line_up" # Maps the 'w' key move_line_up -"C-S-esc" = "extend_line" # Maps Control-Shift-Escape to extend_line +"C-S-esc" = "extend_line" # Maps Ctrl-Shift-Escape to extend_line g = { a = "code_action" } # Maps `ga` to show possible code actions "ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode @@ -25,7 +25,7 @@ j = { k = "normal_mode" } # Maps `jk` to exit insert mode ``` > NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command. -Control, Shift and Alt modifiers are encoded respectively with the prefixes +Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes `C-`, `S-` and `A-`. Special keys are encoded as follows: | Key name | Representation | diff --git a/book/src/themes.md b/book/src/themes.md index 9908456f..0f94828e 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -13,10 +13,10 @@ The default theme.toml can be found [here](https://github.com/helix-editor/helix Each line in the theme file is specified as below: ```toml -key = { fg = "#ffffff", bg = "#000000", modifiers = ["bold", "italic"] } +key = { fg = "#ffffff", bg = "#000000", underline = { color = "#ff0000", style = "curl"}, modifiers = ["bold", "italic"] } ``` -where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, and `modifiers` is a list of style modifiers. `bg` and `modifiers` can be omitted to defer to the defaults. +where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, `underline` the underline `style`/`color`, and `modifiers` is a list of style modifiers. `bg`, `underline` and `modifiers` can be omitted to defer to the defaults. To specify only the foreground color: @@ -77,17 +77,50 @@ The following values may be used as modifiers. Less common modifiers might not be supported by your terminal emulator. +| Modifier | +| --- | +| `bold` | +| `dim` | +| `italic` | +| `underlined` | +| `slow_blink` | +| `rapid_blink` | +| `reversed` | +| `hidden` | +| `crossed_out` | + +> Note: The `underlined` modifier is deprecated and only available for backwards compatibility. +> Its behavior is equivalent to setting `underline.style="line"`. + +### Underline Style + +One of the following values may be used as a value for `underline.style`. + +Some styles might not be supported by your terminal emulator. + | Modifier | | --- | -| `bold` | -| `dim` | -| `italic` | -| `underlined` | -| `slow_blink` | -| `rapid_blink` | -| `reversed` | -| `hidden` | -| `crossed_out` | +| `line` | +| `curl` | +| `dashed` | +| `dotted` | +| `double_line` | + + +### Inheritance + +Extend upon other themes by setting the `inherits` property to an existing theme. + +```toml +inherits = "boo_berry" + +# Override the theming for "keyword"s: +"keyword" = { fg = "gold" } + +# Override colors in the palette: +[palette] +berry = "#2A2A4D" +``` ### Scopes @@ -210,49 +243,54 @@ These scopes are used for theming the editor interface. - `hover` - for hover popup ui -| Key | Notes | -| --- | --- | -| `ui.background` | | -| `ui.background.separator` | Picker separator below input line | -| `ui.cursor` | | -| `ui.cursor.insert` | | -| `ui.cursor.select` | | -| `ui.cursor.match` | Matching bracket etc. | -| `ui.cursor.primary` | Cursor with primary selection | -| `ui.linenr` | Line numbers | -| `ui.linenr.selected` | Line number for the line the cursor is on | -| `ui.statusline` | Statusline | -| `ui.statusline.inactive` | Statusline (unfocused document) | -| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.separator` | Separator character in statusline | -| `ui.popup` | Documentation popups (e.g space-k) | -| `ui.popup.info` | Prompt for multiple key options | -| `ui.window` | Border lines separating splits | -| `ui.help` | Description box for commands | -| `ui.text` | Command prompts, popup text, etc. | -| `ui.text.focus` | | -| `ui.text.info` | The key: command text in `ui.popup.info` boxes | -| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section])| -| `ui.virtual.whitespace` | Visible white-space characters | -| `ui.virtual.indent-guide` | Vertical indent width guides | -| `ui.menu` | Code and command completion menus | -| `ui.menu.selected` | Selected autocomplete item | -| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | -| `ui.selection` | For selections in the editing area | -| `ui.selection.primary` | | -| `ui.cursorline.primary` | The line of the primary cursor | -| `ui.cursorline.secondary` | The lines of any other cursors | -| `warning` | Diagnostics warning (gutter) | -| `error` | Diagnostics error (gutter) | -| `info` | Diagnostics info (gutter) | -| `hint` | Diagnostics hint (gutter) | -| `diagnostic` | Diagnostics fallback style (editing area) | -| `diagnostic.hint` | Diagnostics hint (editing area) | -| `diagnostic.info` | Diagnostics info (editing area) | -| `diagnostic.warning` | Diagnostics warning (editing area) | -| `diagnostic.error` | Diagnostics error (editing area) | +| Key | Notes | +| --- | --- | +| `ui.background` | | +| `ui.background.separator` | Picker separator below input line | +| `ui.cursor` | | +| `ui.cursor.insert` | | +| `ui.cursor.select` | | +| `ui.cursor.match` | Matching bracket etc. | +| `ui.cursor.primary` | Cursor with primary selection | +| `ui.gutter` | Gutter | +| `ui.gutter.selected` | Gutter for the line the cursor is on | +| `ui.linenr` | Line numbers | +| `ui.linenr.selected` | Line number for the line the cursor is on | +| `ui.statusline` | Statusline | +| `ui.statusline.inactive` | Statusline (unfocused document) | +| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.separator` | Separator character in statusline | +| `ui.popup` | Documentation popups (e.g Space + k) | +| `ui.popup.info` | Prompt for multiple key options | +| `ui.window` | Border lines separating splits | +| `ui.help` | Description box for commands | +| `ui.text` | Command prompts, popup text, etc. | +| `ui.text.focus` | | +| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) | +| `ui.text.info` | The key: command text in `ui.popup.info` boxes | +| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | +| `ui.virtual.whitespace` | Visible whitespace characters | +| `ui.virtual.indent-guide` | Vertical indent width guides | +| `ui.menu` | Code and command completion menus | +| `ui.menu.selected` | Selected autocomplete item | +| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | +| `ui.selection` | For selections in the editing area | +| `ui.selection.primary` | | +| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | +| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | +| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | +| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | +| `warning` | Diagnostics warning (gutter) | +| `error` | Diagnostics error (gutter) | +| `info` | Diagnostics info (gutter) | +| `hint` | Diagnostics hint (gutter) | +| `diagnostic` | Diagnostics fallback style (editing area) | +| `diagnostic.hint` | Diagnostics hint (editing area) | +| `diagnostic.info` | Diagnostics info (editing area) | +| `diagnostic.warning` | Diagnostics warning (editing area) | +| `diagnostic.error` | Diagnostics error (editing area) | You can check compliance to spec with diff --git a/book/src/usage.md b/book/src/usage.md index fc3a83ee..a6eb9ec1 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -53,7 +53,7 @@ Multiple characters are currently not supported, but planned. ## Syntax-tree Motions -`A-p`, `A-o`, `A-i`, and `A-n` (or `Alt` and arrow keys) move the primary +`Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) move the primary selection according to the selection's place in the syntax tree. Let's walk through an example to get familiar with them. Many languages have a syntax like so for function calls: @@ -100,13 +100,13 @@ in the tree above. func([arg1], arg2, arg3) ``` -Using `A-n` would select the next sibling in the syntax tree: `arg2`. +Using `Alt-n` would select the next sibling in the syntax tree: `arg2`. ``` func(arg1, [arg2], arg3) ``` -While `A-o` would expand the selection to the parent node. In the tree above we +While `Alt-o` would expand the selection to the parent node. In the tree above we can see that we would select the `arguments` node. ``` @@ -114,10 +114,10 @@ func[(arg1, arg2, arg3)] ``` There is also some nuanced behavior that prevents you from getting stuck on a -node with no sibling. If we have a selection on `arg1`, `A-p` would bring us +node with no sibling. If we have a selection on `arg1`, `Alt-p` would bring us to the previous child node. Since `arg1` doesn't have a sibling to its left, -though, we climb the syntax tree and then take the previous selection. So `A-p` -will move the selection over to the "func" `identifier`. +though, we climb the syntax tree and then take the previous selection. So +`Alt-p` will move the selection over to the "func" `identifier`. ``` [func](arg1, arg2, arg3) @@ -143,6 +143,7 @@ will move the selection over to the "func" `identifier`. | `a` | Argument/parameter | | `o` | Comment | | `t` | Test | +| `g` | Change | > NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current document and a special tree-sitter query file to work properly. [Only diff --git a/book/theme/favicon.png b/book/theme/favicon.png index a5b1aa16..1baa7c51 100644 Binary files a/book/theme/favicon.png and b/book/theme/favicon.png differ diff --git a/book/theme/favicon.svg b/book/theme/favicon.svg index 90e0ea58..05dd73a8 100644 --- a/book/theme/favicon.svg +++ b/book/theme/favicon.svg @@ -1,22 +1 @@ - - - - - + \ No newline at end of file diff --git a/contrib/completion/hx.bash b/contrib/completion/hx.bash index 8a2d9777..01b42deb 100644 --- a/contrib/completion/hx.bash +++ b/contrib/completion/hx.bash @@ -16,8 +16,8 @@ _hx() { COMPREPLY=($(compgen -W "$languages" -- $2)) ;; *) - COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config" -- $2)) + 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/contrib/completion/hx.elv b/contrib/completion/hx.elv index d3d227bc..42c88585 100644 --- a/contrib/completion/hx.elv +++ b/contrib/completion/hx.elv @@ -36,6 +36,11 @@ set edit:completion:arg-completer[hx] = {|@args| edit:complete-filename $args[-1] | each { |v| put $v[stem] } return } + # When we have --log, we need a file + if (has-values "log" $args[-2]) { + edit:complete-filename $args[-1] | each { |v| put $v[stem] } + return + } } edit:complete-filename $args[-1] | each { |v| put $v[stem]} $candidate "--help" "(Prints help information)" @@ -46,4 +51,5 @@ set edit:completion:arg-completer[hx] = {|@args| $candidate "--vsplit" "(Splits all given files vertically)" $candidate "--hsplit" "(Splits all given files horizontally)" $candidate "--config" "(Specifies a file to use for configuration)" -} \ No newline at end of file + $candidate "--log" "(Specifies a file to write log data into)" +} diff --git a/contrib/completion/hx.fish b/contrib/completion/hx.fish index 65f248d4..11977605 100644 --- a/contrib/completion/hx.fish +++ b/contrib/completion/hx.fish @@ -11,4 +11,5 @@ complete -c hx -s v -o vv -o vvv -d "Increases logging verbosity" complete -c hx -s V -l version -d "Prints version information" complete -c hx -l vsplit -d "Splits all given files vertically into different windows" complete -c hx -l hsplit -d "Splits all given files horizontally into different windows" -complete -c hx -s c -l config -d "Specifies a file to use for completion" +complete -c hx -s c -l config -r -d "Specifies a file to use for completion" +complete -c hx -l log -r -d "Specifies a file to write log data into" diff --git a/contrib/completion/hx.zsh b/contrib/completion/hx.zsh index e3375656..aaad6f84 100644 --- a/contrib/completion/hx.zsh +++ b/contrib/completion/hx.zsh @@ -18,6 +18,7 @@ _hx() { "--hsplit[Splits all given files horizontally into different windows]" \ "-c[Specifies a file to use for configuration]" \ "--config[Specifies a file to use for configuration]" \ + "--log[Specifies a file to write log data into]" \ "*:file:_files" case "$state" in diff --git a/contrib/helix.png b/contrib/helix.png index bef00b98..a9b699a4 100644 Binary files a/contrib/helix.png and b/contrib/helix.png differ diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e7b39b06..491cd424 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -35,8 +35,15 @@ to `cargo install` anything either). Integration tests for helix-term can be run with `cargo integration-test`. Code contributors are strongly encouraged to write integration tests for their code. Existing tests can be used as examples. Helpers can be found in -[helpers.rs][helpers.rs] +[helpers.rs][helpers.rs]. The log level can be set with the `HELIX_LOG_LEVEL` +environment variable, e.g. `HELIX_LOG_LEVEL=debug cargo integration-test`. +## Minimum Stable Rust Version (MSRV) Policy + +Helix follows the MSRV of Firefox. +The current MSRV and future changes to the MSRV are listed in the [Firefox documentation]. + +[Firefox documentation]: https://firefox-source-docs.mozilla.org/writing-rust-code/update-policy.html [good-first-issue]: https://github.com/helix-editor/helix/labels/E-easy [log-file]: https://github.com/helix-editor/helix/wiki/FAQ#access-the-log-file [architecture.md]: ./architecture.md diff --git a/flake.lock b/flake.lock index f28ec884..4cf1018c 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "crane": { "flake": false, "locked": { - "lastModified": 1661875961, - "narHash": "sha256-f1h/2c6Teeu1ofAHWzrS8TwBPcnN+EEu+z1sRVmMQTk=", + "lastModified": 1670900067, + "narHash": "sha256-VXVa+KBfukhmWizaiGiHRVX/fuk66P8dgSFfkVN4/MY=", "owner": "ipetkov", "repo": "crane", - "rev": "d9f394e4e20e97c2a60c3ad82c2b6ef99be19e24", + "rev": "59b31b41a589c0a65e4a1f86b0e5eac68081468b", "type": "github" }, "original": { @@ -19,11 +19,11 @@ "devshell": { "flake": false, "locked": { - "lastModified": 1660811669, - "narHash": "sha256-V6lmsaLNFz41myppL0yxglta92ijkSvpZ+XVygAh+bU=", + "lastModified": 1667210711, + "narHash": "sha256-IoErjXZAkzYWHEpQqwu/DeRNJGFdR7X2OGbkhMqMrpw=", "owner": "numtide", "repo": "devshell", - "rev": "c2feacb46ee69949124c835419861143c4016fb5", + "rev": "96a9dd12b8a447840cc246e17a47b81a4268bba7", "type": "github" }, "original": { @@ -35,45 +35,49 @@ "dream2nix": { "inputs": { "alejandra": [ - "nci", - "nixpkgs" + "nci" + ], + "all-cabal-json": [ + "nci" ], "crane": "crane", "devshell": [ "nci", "devshell" ], + "flake-parts": "flake-parts", "flake-utils-pre-commit": [ - "nci", - "nixpkgs" + "nci" + ], + "ghc-utils": [ + "nci" ], "gomod2nix": [ - "nci", - "nixpkgs" + "nci" ], "mach-nix": [ - "nci", - "nixpkgs" + "nci" + ], + "nix-pypi-fetcher": [ + "nci" ], "nixpkgs": [ "nci", "nixpkgs" ], "poetry2nix": [ - "nci", - "nixpkgs" + "nci" ], "pre-commit-hooks": [ - "nci", - "nixpkgs" + "nci" ] }, "locked": { - "lastModified": 1662176993, - "narHash": "sha256-Sy7DsGAveDUFBb6YDsUSYZd/AcXfP/MOMIwMt/NgY84=", + "lastModified": 1671323629, + "narHash": "sha256-9KHTPjIDjfnzZ4NjpE3gGIVHVHopy6weRDYO/7Y3hF8=", "owner": "nix-community", "repo": "dream2nix", - "rev": "809bc5940214744eb29778a9a0b03f161979c1b2", + "rev": "2d7d68505c8619410df2c6b6463985f97cbcba6e", "type": "github" }, "original": { @@ -82,13 +86,31 @@ "type": "github" } }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1668450977, + "narHash": "sha256-cfLhMhnvXn6x1vPm+Jow3RiFAUSCw/l1utktCw5rVA4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "d591857e9d7dd9ddbfba0ea02b43b927c3c0f1fa", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "flake-utils": { "locked": { - "lastModified": 1656928814, - "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", "owner": "numtide", "repo": "flake-utils", - "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", "type": "github" }, "original": { @@ -109,11 +131,11 @@ ] }, "locked": { - "lastModified": 1662177071, - "narHash": "sha256-x6XF//RdZlw81tFAYM1TkjY+iQIpyMCWZ46r9o4wVQY=", + "lastModified": 1671430291, + "narHash": "sha256-UIc7H8F3N8rK72J/Vj5YJdV72tvDvYjH+UPsOFvlcsE=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "65270dea87bb82fc02102a15221677eea237680e", + "rev": "b1b0d38b8c3b0d0e6a38638d5bbe10b0bc67522c", "type": "github" }, "original": { @@ -124,11 +146,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1662019588, - "narHash": "sha256-oPEjHKGGVbBXqwwL+UjsveJzghWiWV0n9ogo1X6l4cw=", + "lastModified": 1671359686, + "narHash": "sha256-3MpC6yZo+Xn9cPordGz2/ii6IJpP2n8LE8e/ebUXLrs=", "owner": "nixos", "repo": "nixpkgs", - "rev": "2da64a81275b68fdad38af669afeda43d401e94b", + "rev": "04f574a1c0fde90b51bf68198e2297ca4e7cccf4", "type": "github" }, "original": { @@ -138,6 +160,24 @@ "type": "github" } }, + "nixpkgs-lib": { + "locked": { + "dir": "lib", + "lastModified": 1665349835, + "narHash": "sha256-UK4urM3iN80UXQ7EaOappDzcisYIuEURFRoGQ/yPkug=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "34c5293a71ffdb2fe054eb5288adc1882c1eb0b1", + "type": "github" + }, + "original": { + "dir": "lib", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "nci": "nci", @@ -153,11 +193,11 @@ ] }, "locked": { - "lastModified": 1662087605, - "narHash": "sha256-Gpf2gp2JenKGf+TylX/YJpttY2bzsnvAMLdLaxoZRyU=", + "lastModified": 1671416426, + "narHash": "sha256-kpSH1Jrxfk2qd0pRPJn1eQdIOseGv5JuE+YaOrqU9s4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "60c2cfaa8b90ed8cebd18b214fac8682dcf222dd", + "rev": "fbaaff24f375ac25ec64268b0a0d63f91e474b7d", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 8cb4b663..fe1c6b44 100644 --- a/flake.nix +++ b/flake.nix @@ -21,57 +21,124 @@ ... }: let lib = nixpkgs.lib; + ncl = nci.lib.nci-lib; mkRootPath = rel: builtins.path { path = "${toString ./.}/${rel}"; name = rel; }; + filteredSource = let + pathsToIgnore = [ + ".envrc" + ".ignore" + ".github" + "runtime" + "screenshot.png" + "book" + "contrib" + "docs" + "README.md" + "CHANGELOG.md" + "shell.nix" + "default.nix" + "grammars.nix" + "flake.nix" + "flake.lock" + ]; + ignorePaths = path: type: let + # split the nix store path into its components + components = lib.splitString "/" path; + # drop off the `/nix/hash-source` section from the path + relPathComponents = lib.drop 4 components; + # reassemble the path components + relPath = lib.concatStringsSep "/" relPathComponents; + in + lib.all (p: ! (lib.hasPrefix p relPath)) pathsToIgnore; + in + builtins.path { + name = "helix-source"; + path = toString ./.; + # filter out unnecessary paths + filter = ignorePaths; + }; outputs = nci.lib.makeOutputs { root = ./.; - renameOutputs = {"helix-term" = "helix";}; - # Set default app to hx (binary is from helix-term release build) - # Set default package to helix-term release build - defaultOutputs = { - app = "hx"; - package = "helix"; + config = common: { + outputs = { + # rename helix-term to helix since it's our main package + rename = {"helix-term" = "helix";}; + # Set default app to hx (binary is from helix-term release build) + # Set default package to helix-term release build + defaults = { + app = "hx"; + package = "helix"; + }; + }; + cCompiler.package = with common.pkgs; + if stdenv.isLinux + then gcc + else clang; + shell = { + packages = with common.pkgs; + [lld_13 cargo-flamegraph rust-analyzer] + ++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin) + ++ (lib.optional stdenv.isLinux lldb); + env = [ + { + name = "HELIX_RUNTIME"; + eval = "$PWD/runtime"; + } + { + name = "RUST_BACKTRACE"; + value = "1"; + } + { + name = "RUSTFLAGS"; + value = + if common.pkgs.stdenv.isLinux + then "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment" + else ""; + } + ]; + }; }; - overrides = { - cCompiler = common: - with common.pkgs; - if stdenv.isLinux - then gcc - else clang; - crateOverrides = common: _: { - helix-term = prev: { - src = builtins.path { - name = "helix-source"; - path = toString ./.; - # filter out unneeded stuff that cause rebuilds - filter = path: type: - lib.all - (n: builtins.baseNameOf path != n) - [ - ".envrc" - ".ignore" - ".github" - "runtime" - "screenshot.png" - "book" - "contrib" - "docs" - "README.md" - "shell.nix" - "default.nix" - "grammars.nix" - "flake.nix" - "flake.lock" - ]; - }; + pkgConfig = common: { + helix-term = { + # Wrap helix with runtime + wrapper = _: old: let + inherit (common) pkgs; + makeOverridableHelix = old: config: let + grammars = pkgs.callPackage ./grammars.nix config; + runtimeDir = pkgs.runCommand "helix-runtime" {} '' + mkdir -p $out + ln -s ${mkRootPath "runtime"}/* $out + rm -r $out/grammars + ln -s ${grammars} $out/grammars + ''; + helix-wrapped = + common.internal.pkgsSet.utils.wrapDerivation old + { + nativeBuildInputs = [pkgs.makeWrapper]; + makeWrapperArgs = config.makeWrapperArgs or []; + } + '' + rm -rf $out/bin + mkdir -p $out/bin + ln -sf ${old}/bin/* $out/bin/ + wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}" + ''; + in + helix-wrapped + // {override = makeOverridableHelix old;}; + in + makeOverridableHelix old {}; + overrides.fix-build.overrideAttrs = prev: { + src = filteredSource; # disable fetching and building of tree-sitter grammars in the helix-term build.rs HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1"; - buildInputs = (prev.buildInputs or []) ++ [common.cCompiler.cc.lib]; + buildInputs = ncl.addBuildInputs prev [common.config.cCompiler.package.cc.lib]; # link languages and theme toml files since helix-term expects them (for tests) preConfigure = '' @@ -83,92 +150,25 @@ ["languages.toml" "theme.toml" "base16_theme.toml"] } ''; + checkPhase = ":"; meta.mainProgram = "hx"; }; }; - shell = common: prev: { - packages = - prev.packages - ++ ( - with common.pkgs; - [lld_13 cargo-flamegraph rust-analyzer] - ++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin) - ++ (lib.optional stdenv.isLinux lldb) - ); - env = - prev.env - ++ [ - { - name = "HELIX_RUNTIME"; - eval = "$PWD/runtime"; - } - { - name = "RUST_BACKTRACE"; - value = "1"; - } - { - name = "RUSTFLAGS"; - value = - if common.pkgs.stdenv.isLinux - then "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment" - else ""; - } - ]; - }; }; }; - makeOverridableHelix = system: old: config: let - pkgs = nixpkgs.legacyPackages.${system}; - grammars = pkgs.callPackage ./grammars.nix config; - runtimeDir = pkgs.runCommand "helix-runtime" {} '' - mkdir -p $out - ln -s ${mkRootPath "runtime"}/* $out - rm -r $out/grammars - ln -s ${grammars} $out/grammars - ''; - helix-wrapped = - pkgs.runCommand "${old.name}-wrapped" - { - inherit (old) pname version meta; - - nativeBuildInputs = [pkgs.makeWrapper]; - makeWrapperArgs = config.makeWrapperArgs or []; - } - '' - mkdir -p $out - cp -r --no-preserve=mode,ownership ${old}/* $out/ - chmod +x $out/bin/* - wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}" - ''; - in - helix-wrapped - // {override = makeOverridableHelix system old;}; in outputs // { - apps = - lib.mapAttrs - ( - system: apps: rec { - default = hx; - hx = { - type = "app"; - program = lib.getExe self.${system}.packages.helix; - }; - } - ) - outputs.apps; packages = lib.mapAttrs ( - system: packages: rec { - default = helix; - helix = makeOverridableHelix system helix-unwrapped {}; - helix-debug = makeOverridableHelix system helix-unwrapped-debug {}; - helix-unwrapped = packages.helix; - helix-unwrapped-debug = packages.helix-debug; - } + system: packages: + packages + // { + helix-unwrapped = packages.helix.passthru.unwrapped; + helix-unwrapped-dev = packages.helix-dev.passthru.unwrapped; + } ) outputs.packages; }; diff --git a/grammars.nix b/grammars.nix index 066fa69d..9ca0cf3d 100644 --- a/grammars.nix +++ b/grammars.nix @@ -2,7 +2,7 @@ stdenv, lib, runCommandLocal, - runCommandNoCC, + runCommand, yj, includeGrammarIf ? _: true, ... @@ -115,7 +115,7 @@ builtins.map (grammar: "ln -s ${grammar.artifact}/${grammar.name}.so $out/${grammar.name}.so") builtGrammars; in - runCommandNoCC "consolidated-helix-grammars" {} '' + runCommand "consolidated-helix-grammars" {} '' mkdir -p $out ${builtins.concatStringsSep "\n" grammarLinks} '' diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 3ea7235d..31b6546f 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -17,32 +17,35 @@ integration = [] [dependencies] helix-loader = { version = "0.6", path = "../helix-loader" } -ropey = { version = "1.5", default-features = false, features = ["simd"] } -smallvec = "1.9" +ropey = { version = "1.5.1-alpha", default-features = false, features = ["simd"] } +smallvec = "1.10" smartstring = "1.0.1" -unicode-segmentation = "1.9" +unicode-segmentation = "1.10" unicode-width = "0.1" -unicode-general-category = "0.5" +unicode-general-category = "0.6" # slab = "0.4.2" slotmap = "1.0" tree-sitter = "0.20" -once_cell = "1.14" +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"] } serde_json = "1.0" toml = "0.5" -similar = "2.2" +imara-diff = "0.1.0" encoding_rs = "0.8" chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } etcetera = "0.4" -textwrap = "0.15.0" +textwrap = "0.16.0" [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index ff680a77..072c93d0 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use smallvec::SmallVec; // Heavily based on https://github.com/codemirror/closebrackets/ - pub const DEFAULT_PAIRS: &[(char, char)] = &[ ('(', ')'), ('{', '}'), @@ -147,13 +146,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option { } /// calculate what the resulting range should be for an auto pair insertion -fn get_next_range( - doc: &Rope, - start_range: &Range, - offset: usize, - typed_char: char, - len_inserted: usize, -) -> Range { +fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range { // When the character under the cursor changes due to complete pair // insertion, we must look backward a grapheme and then add the length // of the insertion to put the resulting cursor in the right place, e.g. @@ -173,8 +166,8 @@ fn get_next_range( // inserting at the very end of the document after the last newline if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() { return Range::new( - start_range.anchor + offset + typed_char.len_utf8(), - start_range.head + offset + typed_char.len_utf8(), + start_range.anchor + offset + 1, + start_range.head + offset + 1, ); } @@ -204,21 +197,18 @@ fn get_next_range( // trivial case: only inserted a single-char opener, just move the selection if len_inserted == 1 { let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward { - start_range.anchor + offset + typed_char.len_utf8() + start_range.anchor + offset + 1 } else { start_range.anchor + offset }; - return Range::new( - end_anchor, - start_range.head + offset + typed_char.len_utf8(), - ); + return Range::new(end_anchor, start_range.head + offset + 1); } // If the head = 0, then we must be in insert mode with a backward // cursor, which implies the head will just move let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward { - start_range.head + offset + typed_char.len_utf8() + start_range.head + offset + 1 } else { // We must have a forward cursor, which means we must move to the // other end of the grapheme to get to where the new characters @@ -244,8 +234,7 @@ fn get_next_range( (_, Direction::Forward) => { if single_grapheme { - graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) - + typed_char.len_utf8() + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) + 1 // if we are appending, the anchor stays where it is; only offset // for multiple range insertions @@ -259,7 +248,9 @@ fn get_next_range( // if we're backward, then the head is at the first char // of the typed char, so we need to add the length of // the closing char - graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + + len_inserted + + offset } else { // when we are inserting in front of a selection, we need to move // the anchor over by however many characters were inserted overall @@ -280,9 +271,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let next_char = doc.get_char(cursor); let len_inserted; + // Since auto pairs are currently limited to single chars, we're either + // inserting exactly one or two chars. When arbitrary length pairs are + // added, these will need to be changed. let change = match next_char { Some(_) if !pair.should_close(doc, start_range) => { - len_inserted = pair.open.len_utf8(); + len_inserted = 1; let mut tendril = Tendril::new(); tendril.push(pair.open); (cursor, cursor, Some(tendril)) @@ -290,12 +284,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { _ => { // insert open & close let pair_str = Tendril::from_iter([pair.open, pair.close]); - len_inserted = pair.open.len_utf8() + pair.close.len_utf8(); + len_inserted = 2; (cursor, cursor, Some(pair_str)) } }; - let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); + let next_range = get_next_range(doc, start_range, offs, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -309,7 +303,6 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); - let mut offs = 0; let transaction = Transaction::change_by_selection(doc, selection, |start_range| { @@ -321,13 +314,13 @@ fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { // return transaction that moves past close (cursor, cursor, None) // no-op } else { - len_inserted += pair.close.len_utf8(); + len_inserted = 1; let mut tendril = Tendril::new(); tendril.push(pair.close); (cursor, cursor, Some(tendril)) }; - let next_range = get_next_range(doc, start_range, offs, pair.close, len_inserted); + let next_range = get_next_range(doc, start_range, offs, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -363,11 +356,11 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { pair_str.push(pair.close); } - len_inserted += pair_str.len(); + len_inserted += pair_str.chars().count(); (cursor, cursor, Some(pair_str)) }; - let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); + let next_range = get_next_range(doc, start_range, offs, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -378,551 +371,3 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { log::debug!("auto pair transaction: {:#?}", t); t } - -#[cfg(test)] -mod test { - use super::*; - use smallvec::smallvec; - - const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str(); - - fn differing_pairs() -> impl Iterator { - DEFAULT_PAIRS.iter().filter(|(open, close)| open != close) - } - - fn matching_pairs() -> impl Iterator { - DEFAULT_PAIRS.iter().filter(|(open, close)| open == close) - } - - fn test_hooks( - in_doc: &Rope, - in_sel: &Selection, - ch: char, - pairs: &[(char, char)], - expected_doc: &Rope, - expected_sel: &Selection, - ) { - let pairs = AutoPairs::new(pairs.iter()); - let trans = hook(in_doc, in_sel, ch, &pairs).unwrap(); - let mut actual_doc = in_doc.clone(); - assert!(trans.apply(&mut actual_doc)); - assert_eq!(expected_doc, &actual_doc); - assert_eq!(expected_sel, trans.selection().unwrap()); - } - - fn test_hooks_with_pairs( - in_doc: &Rope, - in_sel: &Selection, - test_pairs: I, - pairs: &[(char, char)], - get_expected_doc: F, - actual_sel: &Selection, - ) where - I: IntoIterator, - F: Fn(char, char) -> R, - R: Into, - Rope: From, - { - test_pairs.into_iter().for_each(|(open, close)| { - test_hooks( - in_doc, - in_sel, - *open, - pairs, - &Rope::from(get_expected_doc(*open, *close)), - actual_sel, - ) - }); - } - - // [] indicates range - - /// [] -> insert ( -> ([]) - #[test] - fn test_insert_blank() { - test_hooks_with_pairs( - &Rope::from(LINE_END), - &Selection::single(1, 0), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| format!("{}{}{}", open, close, LINE_END), - &Selection::single(2, 1), - ); - - let empty_doc = Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)); - - test_hooks_with_pairs( - &empty_doc, - &Selection::single(empty_doc.len_chars(), LINE_END.len()), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| { - format!( - "{line_end}{open}{close}{line_end}", - open = open, - close = close, - line_end = LINE_END - ) - }, - &Selection::single(LINE_END.len() + 2, LINE_END.len() + 1), - ); - } - - #[test] - fn test_insert_before_multi_code_point_graphemes() { - for (_, close) in differing_pairs() { - test_hooks( - &Rope::from(format!("hello 👨‍👩‍👧‍👦 goodbye{}", LINE_END)), - &Selection::single(13, 6), - *close, - DEFAULT_PAIRS, - &Rope::from(format!("hello {}👨‍👩‍👧‍👦 goodbye{}", close, LINE_END)), - &Selection::single(14, 7), - ); - } - } - - #[test] - fn test_insert_at_end_of_document() { - test_hooks_with_pairs( - &Rope::from(LINE_END), - &Selection::single(LINE_END.len(), LINE_END.len()), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| format!("{}{}{}", LINE_END, open, close), - &Selection::single(LINE_END.len() + 1, LINE_END.len() + 1), - ); - - test_hooks_with_pairs( - &Rope::from(format!("foo{}", LINE_END)), - &Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| format!("foo{}{}{}", LINE_END, open, close), - &Selection::single(LINE_END.len() + 4, LINE_END.len() + 4), - ); - } - - /// [] -> append ( -> ([]) - #[test] - fn test_append_blank() { - test_hooks_with_pairs( - // this is what happens when you have a totally blank document and then append - &Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)), - // before inserting the pair, the cursor covers all of both empty lines - &Selection::single(0, LINE_END.len() * 2), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| { - format!( - "{line_end}{open}{close}{line_end}", - line_end = LINE_END, - open = open, - close = close - ) - }, - // after inserting pair, the cursor covers the first new line and the open char - &Selection::single(0, LINE_END.len() + 2), - ); - } - - /// [] ([]) - /// [] -> insert -> ([]) - /// [] ([]) - #[test] - fn test_insert_blank_multi_cursor() { - test_hooks_with_pairs( - &Rope::from("\n\n\n"), - &Selection::new( - smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),), - 0, - ), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| { - format!( - "{open}{close}\n{open}{close}\n{open}{close}\n", - open = open, - close = close - ) - }, - &Selection::new( - smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),), - 0, - ), - ); - } - - /// fo[o] -> append ( -> fo[o(]) - #[test] - fn test_append() { - test_hooks_with_pairs( - &Rope::from("foo\n"), - &Selection::single(2, 4), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("foo{}{}\n", open, close), - &Selection::single(2, 5), - ); - } - - /// foo[] -> append to end of line ( -> foo([]) - #[test] - fn test_append_single_cursor() { - test_hooks_with_pairs( - &Rope::from(format!("foo{}", LINE_END)), - &Selection::single(3, 3 + LINE_END.len()), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("foo{}{}{}", open, close, LINE_END), - &Selection::single(4, 5), - ); - } - - /// fo[o] fo[o(]) - /// fo[o] -> append ( -> fo[o(]) - /// fo[o] fo[o(]) - #[test] - fn test_append_multi() { - test_hooks_with_pairs( - &Rope::from("foo\nfoo\nfoo\n"), - &Selection::new( - smallvec!(Range::new(2, 4), Range::new(6, 8), Range::new(10, 12)), - 0, - ), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| { - format!( - "foo{open}{close}\nfoo{open}{close}\nfoo{open}{close}\n", - open = open, - close = close - ) - }, - &Selection::new( - smallvec!(Range::new(2, 5), Range::new(8, 11), Range::new(14, 17)), - 0, - ), - ); - } - - /// ([)] -> insert ) -> ()[] - #[test] - fn test_insert_close_inside_pair() { - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); - - test_hooks( - &doc, - &Selection::single(2, 1), - *close, - DEFAULT_PAIRS, - &doc, - &Selection::single(2 + LINE_END.len(), 2), - ); - } - } - - /// [(]) -> append ) -> [()] - #[test] - fn test_append_close_inside_pair() { - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); - - test_hooks( - &doc, - &Selection::single(0, 2), - *close, - DEFAULT_PAIRS, - &doc, - &Selection::single(0, 2 + LINE_END.len()), - ); - } - } - - /// ([]) ()[] - /// ([]) -> insert ) -> ()[] - /// ([]) ()[] - #[test] - fn test_insert_close_inside_pair_multi_cursor() { - let sel = Selection::new( - smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),), - 0, - ); - - let expected_sel = Selection::new( - smallvec!(Range::new(3, 2), Range::new(6, 5), Range::new(9, 8),), - 0, - ); - - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!( - "{open}{close}\n{open}{close}\n{open}{close}\n", - open = open, - close = close - )); - - test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); - } - } - - /// [(]) [()] - /// [(]) -> append ) -> [()] - /// [(]) [()] - #[test] - fn test_append_close_inside_pair_multi_cursor() { - let sel = Selection::new( - smallvec!(Range::new(0, 2), Range::new(3, 5), Range::new(6, 8),), - 0, - ); - - let expected_sel = Selection::new( - smallvec!(Range::new(0, 3), Range::new(3, 6), Range::new(6, 9),), - 0, - ); - - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!( - "{open}{close}\n{open}{close}\n{open}{close}\n", - open = open, - close = close - )); - - test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); - } - } - - /// ([]) -> insert ( -> (([])) - #[test] - fn test_insert_open_inside_pair() { - let sel = Selection::single(2, 1); - let expected_sel = Selection::single(3, 2); - - for (open, close) in differing_pairs() { - let doc = Rope::from(format!("{}{}", open, close)); - let expected_doc = Rope::from(format!( - "{open}{open}{close}{close}", - open = open, - close = close - )); - - test_hooks( - &doc, - &sel, - *open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - - /// [word(]) -> append ( -> [word((])) - #[test] - fn test_append_open_inside_pair() { - let sel = Selection::single(0, 6); - let expected_sel = Selection::single(0, 7); - - for (open, close) in differing_pairs() { - let doc = Rope::from(format!("word{}{}", open, close)); - let expected_doc = Rope::from(format!( - "word{open}{open}{close}{close}", - open = open, - close = close - )); - - test_hooks( - &doc, - &sel, - *open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - - /// ([]) -> insert " -> ("[]") - #[test] - fn test_insert_nested_open_inside_pair() { - let sel = Selection::single(2, 1); - let expected_sel = Selection::single(3, 2); - - for (outer_open, outer_close) in differing_pairs() { - let doc = Rope::from(format!("{}{}", outer_open, outer_close,)); - - for (inner_open, inner_close) in matching_pairs() { - let expected_doc = Rope::from(format!( - "{}{}{}{}", - outer_open, inner_open, inner_close, outer_close - )); - - test_hooks( - &doc, - &sel, - *inner_open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - } - - /// [(]) -> append " -> [("]") - #[test] - fn test_append_nested_open_inside_pair() { - let sel = Selection::single(0, 2); - let expected_sel = Selection::single(0, 3); - - for (outer_open, outer_close) in differing_pairs() { - let doc = Rope::from(format!("{}{}", outer_open, outer_close,)); - - for (inner_open, inner_close) in matching_pairs() { - let expected_doc = Rope::from(format!( - "{}{}{}{}", - outer_open, inner_open, inner_close, outer_close - )); - - test_hooks( - &doc, - &sel, - *inner_open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - } - - /// []word -> insert ( -> ([]word - #[test] - fn test_insert_open_before_non_pair() { - test_hooks_with_pairs( - &Rope::from("word"), - &Selection::single(1, 0), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, _| format!("{}word", open), - &Selection::single(2, 1), - ) - } - - /// [wor]d -> insert ( -> ([wor]d - #[test] - fn test_insert_open_with_selection() { - test_hooks_with_pairs( - &Rope::from("word"), - &Selection::single(3, 0), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, _| format!("{}word", open), - &Selection::single(4, 1), - ) - } - - /// [wor]d -> append ) -> [wor)]d - #[test] - fn test_append_close_inside_non_pair_with_selection() { - let sel = Selection::single(0, 4); - let expected_sel = Selection::single(0, 5); - - for (_, close) in DEFAULT_PAIRS { - let doc = Rope::from("word"); - let expected_doc = Rope::from(format!("wor{}d", close)); - test_hooks( - &doc, - &sel, - *close, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - - /// foo[ wor]d -> insert ( -> foo([) wor]d - #[test] - fn test_insert_open_trailing_word_with_selection() { - test_hooks_with_pairs( - &Rope::from("foo word"), - &Selection::single(7, 3), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("foo{}{} word", open, close), - &Selection::single(9, 4), - ) - } - - /// foo([) wor]d -> insert ) -> foo()[ wor]d - #[test] - fn test_insert_close_inside_pair_trailing_word_with_selection() { - for (open, close) in differing_pairs() { - test_hooks( - &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), - &Selection::single(9, 4), - *close, - DEFAULT_PAIRS, - &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), - &Selection::single(9, 5), - ) - } - } - - /// we want pairs that are *not* the same char to be inserted after - /// a non-pair char, for cases like functions, but for pairs that are - /// the same char, we want to *not* insert a pair to handle cases like "I'm" - /// - /// word[] -> insert ( -> word([]) - /// word[] -> insert ' -> word'[] - #[test] - fn test_insert_open_after_non_pair() { - let doc = Rope::from(format!("word{}", LINE_END)); - let sel = Selection::single(5, 4); - let expected_sel = Selection::single(6, 5); - - test_hooks_with_pairs( - &doc, - &sel, - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("word{}{}{}", open, close, LINE_END), - &expected_sel, - ); - - test_hooks_with_pairs( - &doc, - &sel, - matching_pairs(), - DEFAULT_PAIRS, - |open, _| format!("word{}{}", open, LINE_END), - &expected_sel, - ); - } - - #[test] - fn test_configured_pairs() { - let test_pairs = &[('`', ':'), ('+', '-')]; - - test_hooks_with_pairs( - &Rope::from(LINE_END), - &Selection::single(1, 0), - test_pairs, - test_pairs, - |open, close| format!("{}{}{}", open, close, LINE_END), - &Selection::single(2, 1), - ); - - let doc = Rope::from(format!("foo`: word{}", LINE_END)); - - test_hooks( - &doc, - &Selection::single(9, 4), - ':', - test_pairs, - &doc, - &Selection::single(9, 5), - ) - } -} 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/diagnostic.rs b/helix-core/src/diagnostic.rs index 48a68dc0..6b5da17e 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -29,6 +29,12 @@ pub enum NumberOrString { String(String), } +#[derive(Debug, Clone)] +pub enum DiagnosticTag { + Unnecessary, + Deprecated, +} + /// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html) #[derive(Debug, Clone)] pub struct Diagnostic { @@ -37,4 +43,7 @@ pub struct Diagnostic { pub message: String, pub severity: Option, pub code: Option, + pub tags: Vec, + pub source: Option, + pub data: Option, } diff --git a/helix-core/src/diff.rs b/helix-core/src/diff.rs index 6960c679..a5d6d722 100644 --- a/helix-core/src/diff.rs +++ b/helix-core/src/diff.rs @@ -1,58 +1,194 @@ -use crate::{Rope, Transaction}; +use std::ops::Range; +use std::time::Instant; -/// Compares `old` and `new` to generate a [`Transaction`] describing -/// the steps required to get from `old` to `new`. -pub fn compare_ropes(old: &Rope, new: &Rope) -> Transaction { - // `similar` only works on contiguous data, so a `Rope` has - // to be temporarily converted into a `String`. - let old_converted = old.to_string(); - let new_converted = new.to_string(); - - // A timeout is set so after 1 seconds, the algorithm will start - // approximating. This is especially important for big `Rope`s or - // `Rope`s that are extremely dissimilar to each other. - let mut config = similar::TextDiff::configure(); - config.timeout(std::time::Duration::from_secs(1)); - - let diff = config.diff_chars(&old_converted, &new_converted); - - // The current position of the change needs to be tracked to - // construct the `Change`s. - let mut pos = 0; - Transaction::change( - old, - diff.ops() +use imara_diff::intern::InternedInput; +use imara_diff::Algorithm; +use ropey::RopeSlice; + +use crate::{ChangeSet, Rope, Tendril, Transaction}; + +/// A `imara_diff::Sink` that builds a `ChangeSet` for a character diff of a hunk +struct CharChangeSetBuilder<'a> { + res: &'a mut ChangeSet, + hunk: &'a InternedInput, + pos: u32, +} + +impl imara_diff::Sink for CharChangeSetBuilder<'_> { + type Out = (); + fn process_change(&mut self, before: Range, after: Range) { + self.res.retain((before.start - self.pos) as usize); + self.res.delete(before.len()); + self.pos = before.end; + + let res = self.hunk.after[after.start as usize..after.end as usize] + .iter() + .map(|&token| self.hunk.interner[token]) + .collect(); + + self.res.insert(res); + } + + fn finish(self) -> Self::Out { + self.res.retain(self.hunk.before.len() - self.pos as usize); + } +} + +struct LineChangeSetBuilder<'a> { + res: ChangeSet, + after: RopeSlice<'a>, + file: &'a InternedInput>, + current_hunk: InternedInput, + pos: u32, +} + +impl imara_diff::Sink for LineChangeSetBuilder<'_> { + type Out = ChangeSet; + + fn process_change(&mut self, before: Range, after: Range) { + let len = self.file.before[self.pos as usize..before.start as usize] .iter() - .map(|op| op.as_tag_tuple()) - .filter_map(|(tag, old_range, new_range)| { - // `old_pos..pos` is equivalent to `start..end` for where - // the change should be applied. - let old_pos = pos; - pos += old_range.end - old_range.start; - - match tag { - // Semantically, inserts and replacements are the same thing. - similar::DiffTag::Insert | similar::DiffTag::Replace => { - // This is the text from the `new` rope that should be - // inserted into `old`. - let text: &str = { - let start = new.char_to_byte(new_range.start); - let end = new.char_to_byte(new_range.end); - &new_converted[start..end] - }; - Some((old_pos, pos, Some(text.into()))) + .map(|&it| self.file.interner[it].len_chars()) + .sum(); + self.res.retain(len); + self.pos = before.end; + + // do not perform diffs on large hunks + let len_before = before.end - before.start; + let len_after = after.end - after.start; + + // Pure insertions/removals do not require a character diff. + // Very large changes are ignored because their character diff is expensive to compute + // TODO adjust heuristic to detect large changes? + if len_before == 0 + || len_after == 0 + || len_after > 5 * len_before + || 5 * len_after < len_before && len_before > 10 + || len_before + len_after > 200 + { + let remove = self.file.before[before.start as usize..before.end as usize] + .iter() + .map(|&it| self.file.interner[it].len_chars()) + .sum(); + self.res.delete(remove); + let mut fragment = Tendril::new(); + if len_after > 500 { + // copying a rope line by line is slower then copying the entire + // rope. Use to_string for very large changes instead.. + if self.file.after.len() == after.end as usize { + if after.start == 0 { + fragment = self.after.to_string().into(); + } else { + let start = self.after.line_to_char(after.start as usize); + fragment = self.after.slice(start..).to_string().into(); } - similar::DiffTag::Delete => Some((old_pos, pos, None)), - similar::DiffTag::Equal => None, + } else if after.start == 0 { + let end = self.after.line_to_char(after.end as usize); + fragment = self.after.slice(..end).to_string().into(); + } else { + let start = self.after.line_to_char(after.start as usize); + let end = self.after.line_to_char(after.end as usize); + fragment = self.after.slice(start..end).to_string().into(); } - }), - ) + } else { + for &line in &self.file.after[after.start as usize..after.end as usize] { + for chunk in self.file.interner[line].chunks() { + fragment.push_str(chunk) + } + } + }; + self.res.insert(fragment); + } else { + // for reasonably small hunks, generating a ChangeSet from char diff can save memory + // TODO use a tokenizer (word diff?) for improved performance + let hunk_before = self.file.before[before.start as usize..before.end as usize] + .iter() + .flat_map(|&it| self.file.interner[it].chars()); + let hunk_after = self.file.after[after.start as usize..after.end as usize] + .iter() + .flat_map(|&it| self.file.interner[it].chars()); + self.current_hunk.update_before(hunk_before); + self.current_hunk.update_after(hunk_after); + + // the histogram heuristic does not work as well + // for characters because the same characters often reoccur + // use myer diff instead + imara_diff::diff( + Algorithm::Myers, + &self.current_hunk, + CharChangeSetBuilder { + res: &mut self.res, + hunk: &self.current_hunk, + pos: 0, + }, + ); + + self.current_hunk.clear(); + } + } + + fn finish(mut self) -> Self::Out { + let len = self.file.before[self.pos as usize..] + .iter() + .map(|&it| self.file.interner[it].len_chars()) + .sum(); + + self.res.retain(len); + self.res + } +} + +struct RopeLines<'a>(RopeSlice<'a>); + +impl<'a> imara_diff::intern::TokenSource for RopeLines<'a> { + type Token = RopeSlice<'a>; + type Tokenizer = ropey::iter::Lines<'a>; + + fn tokenize(&self) -> Self::Tokenizer { + self.0.lines() + } + + fn estimate_tokens(&self) -> u32 { + // we can provide a perfect estimate which is very nice for performance + self.0.len_lines() as u32 + } +} + +/// Compares `old` and `new` to generate a [`Transaction`] describing +/// the steps required to get from `old` to `new`. +pub fn compare_ropes(before: &Rope, after: &Rope) -> Transaction { + let start = Instant::now(); + let res = ChangeSet::with_capacity(32); + let after = after.slice(..); + let file = InternedInput::new(RopeLines(before.slice(..)), RopeLines(after)); + let builder = LineChangeSetBuilder { + res, + file: &file, + after, + pos: 0, + current_hunk: InternedInput::default(), + }; + + let res = imara_diff::diff(Algorithm::Histogram, &file, builder).into(); + + log::debug!( + "rope diff took {}s", + Instant::now().duration_since(start).as_secs_f64() + ); + res } #[cfg(test)] mod tests { use super::*; + fn test_identity(a: &str, b: &str) { + let mut old = Rope::from(a); + let new = Rope::from(b); + compare_ropes(&old, &new).apply(&mut old); + assert_eq!(old, new); + } + quickcheck::quickcheck! { fn test_compare_ropes(a: String, b: String) -> bool { let mut old = Rope::from(a); @@ -61,4 +197,25 @@ mod tests { old == new } } + + #[test] + fn equal_files() { + test_identity("foo", "foo"); + } + + #[test] + fn trailing_newline() { + test_identity("foo\n", "foo"); + test_identity("foo", "foo\n"); + } + + #[test] + fn new_file() { + test_identity("", "foo"); + } + + #[test] + fn deleted_file() { + test_identity("foo", ""); + } } diff --git a/helix-core/src/history.rs b/helix-core/src/history.rs index b608097c..1aac38d9 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,21 @@ 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 { + let lca = self.lowest_common_ancestor(revision, self.current); + let up = self.path_up(revision, lca); + let down = self.path_up(self.current, lca); + let up_txns = up + .iter() + .rev() + .map(|&n| self.revisions[n].inversion.clone()); + let down_txns = down.iter().map(|&n| self.revisions[n].transaction.clone()); + + down_txns.chain(up_txns).reduce(|acc, tx| tx.compose(acc)) + } + /// Undo the last edit. pub fn undo(&mut self) -> Option<&Transaction> { if self.at_root() { @@ -282,7 +303,7 @@ impl History { } /// Whether to undo by a number of edits or a duration of time. -#[derive(Debug, PartialEq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum UndoKind { Steps(usize), TimePeriod(std::time::Duration), @@ -366,12 +387,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 +445,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/increment/number.rs b/helix-core/src/increment/number.rs index 62b4a19d..91268729 100644 --- a/helix-core/src/increment/number.rs +++ b/helix-core/src/increment/number.rs @@ -110,8 +110,8 @@ impl<'a> Increment for NumberIncrementor<'a> { let (lower_count, upper_count): (usize, usize) = old_text.chars().skip(2).fold((0, 0), |(lower, upper), c| { ( - lower + c.is_ascii_lowercase().then(|| 1).unwrap_or(0), - upper + c.is_ascii_uppercase().then(|| 1).unwrap_or(0), + lower + usize::from(c.is_ascii_lowercase()), + upper + usize::from(c.is_ascii_uppercase()), ) }); if upper_count > lower_count { diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index ad079c25..d6aa5edb 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -192,13 +192,15 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize) -> usize { /// Computes for node and all ancestors whether they are the first node on their line. /// The first entry in the return value represents the root node, the last one the node itself -fn get_first_in_line(mut node: Node, byte_pos: usize, new_line: bool) -> Vec { +fn get_first_in_line(mut node: Node, new_line_byte_pos: Option) -> Vec { let mut first_in_line = Vec::new(); loop { if let Some(prev) = node.prev_sibling() { // If we insert a new line, the first node at/after the cursor is considered to be the first in its line let first = prev.end_position().row != node.start_position().row - || (new_line && node.start_byte() >= byte_pos && prev.start_byte() < byte_pos); + || new_line_byte_pos.map_or(false, |byte_pos| { + node.start_byte() >= byte_pos && prev.start_byte() < byte_pos + }); first_in_line.push(Some(first)); } else { // Nodes that have no previous siblings are first in their line if and only if their parent is @@ -298,8 +300,21 @@ enum IndentScope { Tail, } -/// Execute the indent query. -/// Returns for each node (identified by its id) a list of indent captures for that node. +/// A capture from the indent query which does not define an indent but extends +/// the range of a node. This is used before the indent is calculated. +enum ExtendCapture { + Extend, + PreventOnce, +} + +/// The result of running a tree-sitter indent query. This stores for +/// each node (identified by its ID) the relevant captures (already filtered +/// by predicates). +struct IndentQueryResult { + indent_captures: HashMap>, + extend_captures: HashMap>, +} + fn query_indents( query: &Query, syntax: &Syntax, @@ -309,8 +324,9 @@ fn query_indents( // Position of the (optional) newly inserted line break. // Given as (line, byte_pos) new_line_break: Option<(usize, usize)>, -) -> HashMap> { +) -> IndentQueryResult { let mut indent_captures: HashMap> = HashMap::new(); + let mut extend_captures: HashMap> = HashMap::new(); cursor.set_byte_range(range); // Iterate over all captures from the query for m in cursor.matches(query, syntax.tree().root_node(), RopeProvider(text)) { @@ -374,10 +390,24 @@ fn query_indents( continue; } for capture in m.captures { - let capture_type = query.capture_names()[capture.index as usize].as_str(); - let capture_type = match capture_type { + let capture_name = query.capture_names()[capture.index as usize].as_str(); + let capture_type = match capture_name { "indent" => IndentCaptureType::Indent, "outdent" => IndentCaptureType::Outdent, + "extend" => { + extend_captures + .entry(capture.node.id()) + .or_insert_with(|| Vec::with_capacity(1)) + .push(ExtendCapture::Extend); + continue; + } + "extend.prevent-once" => { + extend_captures + .entry(capture.node.id()) + .or_insert_with(|| Vec::with_capacity(1)) + .push(ExtendCapture::PreventOnce); + continue; + } _ => { // Ignore any unknown captures (these may be needed for predicates such as #match?) continue; @@ -420,7 +450,74 @@ fn query_indents( .push(indent_capture); } } - indent_captures + IndentQueryResult { + indent_captures, + extend_captures, + } +} + +/// Handle extend queries. deepest_preceding is the deepest descendant of node that directly precedes the cursor position. +/// Any ancestor of deepest_preceding which is also a descendant of node may be "extended". In that case, node will be updated, +/// so that the indent computation starts with the correct syntax node. +fn extend_nodes<'a>( + node: &mut Node<'a>, + mut deepest_preceding: Node<'a>, + extend_captures: &HashMap>, + text: RopeSlice, + line: usize, + tab_width: usize, +) { + 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; + } + } + } + } + } + } + // 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, + } + } } /// Use the syntax tree to determine the indentation for a given position. @@ -459,40 +556,75 @@ fn query_indents( /// }, /// ); /// ``` +#[allow(clippy::too_many_arguments)] pub fn treesitter_indent_for_pos( query: &Query, syntax: &Syntax, indent_style: &IndentStyle, + tab_width: usize, text: RopeSlice, line: usize, pos: usize, new_line: bool, ) -> Option { let byte_pos = text.char_to_byte(pos); + // The innermost tree-sitter node which is considered for the indent + // computation. It may change if some predeceding node is extended let mut node = syntax .tree() .root_node() .descendant_for_byte_range(byte_pos, byte_pos)?; - let mut first_in_line = get_first_in_line(node, byte_pos, new_line); - let new_line_break = if new_line { - Some((line, byte_pos)) - } else { - None + let (query_result, deepest_preceding) = { + // The query range should intersect with all nodes directly preceding + // the position of the indent query in case one of them is extended. + let mut deepest_preceding = None; // The deepest node preceding the indent query position + let mut tree_cursor = node.walk(); + for child in node.children(&mut tree_cursor) { + if child.byte_range().end <= byte_pos { + deepest_preceding = Some(child); + } + } + deepest_preceding = deepest_preceding.map(|mut prec| { + // Get the deepest directly preceding node + while prec.child_count() > 0 { + prec = prec.child(prec.child_count() - 1).unwrap(); + } + prec + }); + let query_range = deepest_preceding + .map(|prec| prec.byte_range().end - 1..byte_pos + 1) + .unwrap_or(byte_pos..byte_pos + 1); + + crate::syntax::PARSER.with(|ts_parser| { + let mut ts_parser = ts_parser.borrow_mut(); + let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); + let query_result = query_indents( + query, + syntax, + &mut cursor, + text, + query_range, + new_line.then(|| (line, byte_pos)), + ); + ts_parser.cursors.push(cursor); + (query_result, deepest_preceding) + }) }; - let query_result = crate::syntax::PARSER.with(|ts_parser| { - let mut ts_parser = ts_parser.borrow_mut(); - let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); - let query_result = query_indents( - query, - syntax, - &mut cursor, + let indent_captures = query_result.indent_captures; + let extend_captures = query_result.extend_captures; + + // Check for extend captures, potentially changing the node that the indent calculation starts with + if let Some(deepest_preceding) = deepest_preceding { + extend_nodes( + &mut node, + deepest_preceding, + &extend_captures, text, - byte_pos..byte_pos + 1, - new_line_break, + line, + tab_width, ); - ts_parser.cursors.push(cursor); - query_result - }); + } + let mut first_in_line = get_first_in_line(node, new_line.then(|| byte_pos)); let mut result = Indentation::default(); // We always keep track of all the indent changes on one line, in order to only indent once @@ -504,7 +636,7 @@ pub fn treesitter_indent_for_pos( // one entry for each ancestor of the node (which is what we iterate over) let is_first = *first_in_line.last().unwrap(); // Apply all indent definitions for this node - if let Some(definitions) = query_result.get(&node.id()) { + if let Some(definitions) = indent_captures.get(&node.id()) { for definition in definitions { match definition.scope { IndentScope::All => { @@ -550,7 +682,13 @@ pub fn treesitter_indent_for_pos( node = parent; first_in_line.pop(); } else { - result.add_line(&indent_for_line_below); + // Only add the indentation for the line below if that line + // is not after the line that the indentation is calculated for. + if (node.start_position().row < line) + || (new_line && node.start_position().row == line && node.start_byte() < byte_pos) + { + result.add_line(&indent_for_line_below); + } result.add_line(&indent_for_line); break; } @@ -579,6 +717,7 @@ pub fn indent_for_newline( query, syntax, indent_style, + tab_width, text, line_before, line_before_end_pos, diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 735a62c1..ee174e69 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; @@ -46,13 +45,45 @@ pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option { /// * Git repository root if no marker detected /// * Top-most folder containing a root marker if not git repository detected /// * Current working directory as fallback -pub fn find_root(root: Option<&str>, root_markers: &[String]) -> Option { - helix_loader::find_root_impl(root, root_markers) - .first() - .cloned() +pub fn find_root(root: Option<&str>, root_markers: &[String]) -> std::path::PathBuf { + let current_dir = std::env::current_dir().expect("unable to determine current directory"); + + let root = match root { + Some(root) => { + let root = std::path::Path::new(root); + if root.is_absolute() { + root.to_path_buf() + } else { + current_dir.join(root) + } + } + None => current_dir.clone(), + }; + + let mut top_marker = None; + for ancestor in root.ancestors() { + if root_markers + .iter() + .any(|marker| ancestor.join(marker).exists()) + { + top_marker = Some(ancestor); + } + + if ancestor.join(".git").exists() { + // Top marker is repo root if not root marker was detected yet + if top_marker.is_none() { + top_marker = Some(ancestor); + } + // Don't go higher than repo if we're in one + break; + } + } + + // Return the found top marker or the current_dir as fallback + top_marker.map_or(current_dir, |a| a.to_path_buf()) } -pub use ropey::{str_utils, Rope, RopeBuilder, RopeSlice}; +pub use ropey::{self, str_utils, Rope, RopeBuilder, RopeSlice}; // pub use tendril::StrTendril as Tendril; pub use smartstring::SmartString; @@ -71,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/line_ending.rs b/helix-core/src/line_ending.rs index 3e8a6cae..09e92523 100644 --- a/helix-core/src/line_ending.rs +++ b/helix-core/src/line_ending.rs @@ -6,7 +6,7 @@ pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::Crlf; pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::LF; /// Represents one of the valid Unicode line endings. -#[derive(PartialEq, Copy, Clone, Debug)] +#[derive(PartialEq, Eq, Copy, Clone, Debug)] pub enum LineEnding { Crlf, // CarriageReturn followed by LineFeed LF, // U+000A -- LineFeed diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index c232484c..278375e8 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -389,6 +389,8 @@ fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> boo } } +/// Finds the range of the next or previous textobject in the syntax sub-tree of `node`. +/// Returns the range in the forwards direction. pub fn goto_treesitter_object( slice: RopeSlice, range: Range, @@ -419,8 +421,8 @@ pub fn goto_treesitter_object( .filter(|n| n.start_byte() > byte_pos) .min_by_key(|n| n.start_byte())?, Direction::Backward => nodes - .filter(|n| n.start_byte() < byte_pos) - .max_by_key(|n| n.start_byte())?, + .filter(|n| n.end_byte() < byte_pos) + .max_by_key(|n| n.end_byte())?, }; let len = slice.len_bytes(); @@ -434,7 +436,7 @@ pub fn goto_treesitter_object( let end_char = slice.byte_to_char(end_byte); // head of range should be at beginning - Some(Range::new(end_char, start_char)) + Some(Range::new(start_char, end_char)) }; (0..count).fold(range, |range, _| get_range(range).unwrap_or(range)) } 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/selection.rs b/helix-core/src/selection.rs index 3463c1d3..ffba46ab 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -122,7 +122,7 @@ impl Range { } } - // flips the direction of the selection + /// Flips the direction of the selection pub fn flip(&self) -> Self { Self { anchor: self.head, @@ -131,6 +131,16 @@ impl Range { } } + /// Returns the selection if it goes in the direction of `direction`, + /// flipping the selection otherwise. + pub fn with_direction(self, direction: Direction) -> Self { + if self.direction() == direction { + self + } else { + self.flip() + } + } + /// Check two ranges for overlap. #[must_use] pub fn overlaps(&self, other: &Self) -> bool { @@ -485,28 +495,53 @@ impl Selection { /// Normalizes a `Selection`. fn normalize(mut self) -> Self { - let primary = self.ranges[self.primary_index]; + let mut primary = self.ranges[self.primary_index]; self.ranges.sort_unstable_by_key(Range::from); + + self.ranges.dedup_by(|curr_range, prev_range| { + if prev_range.overlaps(curr_range) { + let new_range = curr_range.merge(*prev_range); + if prev_range == &primary || curr_range == &primary { + primary = new_range; + } + *prev_range = new_range; + true + } else { + false + } + }); + self.primary_index = self .ranges .iter() .position(|&range| range == primary) .unwrap(); - let mut prev_i = 0; - for i in 1..self.ranges.len() { - if self.ranges[prev_i].overlaps(&self.ranges[i]) { - self.ranges[prev_i] = self.ranges[prev_i].merge(self.ranges[i]); + self + } + + // Merges all ranges that are consecutive + pub fn merge_consecutive_ranges(mut self) -> Self { + let mut primary = self.ranges[self.primary_index]; + + self.ranges.dedup_by(|curr_range, prev_range| { + if prev_range.to() == curr_range.from() { + let new_range = curr_range.merge(*prev_range); + if prev_range == &primary || curr_range == &primary { + primary = new_range; + } + *prev_range = new_range; + true } else { - prev_i += 1; - self.ranges[prev_i] = self.ranges[i]; - } - if i == self.primary_index { - self.primary_index = prev_i; + false } - } + }); - self.ranges.truncate(prev_i + 1); + self.primary_index = self + .ranges + .iter() + .position(|&range| range == primary) + .unwrap(); self } @@ -1122,6 +1157,52 @@ mod test { &["", "abcd", "efg", "rs", "xyz"] ); } + + #[test] + fn test_merge_consecutive_ranges() { + let selection = Selection::new( + smallvec![ + Range::new(0, 1), + Range::new(1, 10), + Range::new(15, 20), + Range::new(25, 26), + Range::new(26, 30) + ], + 4, + ); + + let result = selection.merge_consecutive_ranges(); + + assert_eq!( + result.ranges(), + &[Range::new(0, 10), Range::new(15, 20), Range::new(25, 30)] + ); + assert_eq!(result.primary_index, 2); + + let selection = Selection::new(smallvec![Range::new(0, 1)], 0); + let result = selection.merge_consecutive_ranges(); + + assert_eq!(result.ranges(), &[Range::new(0, 1)]); + assert_eq!(result.primary_index, 0); + + let selection = Selection::new( + smallvec![ + Range::new(0, 1), + Range::new(1, 5), + Range::new(5, 8), + Range::new(8, 10), + Range::new(10, 15), + Range::new(18, 25) + ], + 3, + ); + + let result = selection.merge_consecutive_ranges(); + + assert_eq!(result.ranges(), &[Range::new(0, 15), Range::new(18, 25)]); + assert_eq!(result.primary_index, 0); + } + #[test] fn test_selection_contains() { fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool { diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index 4323039a..9475f5e5 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,109 +1,198 @@ use std::borrow::Cow; -/// Get the vec of escaped / quoted / doublequoted filenames from the input str -pub fn shellwords(input: &str) -> Vec> { - enum State { - Normal, - NormalEscaped, - Quoted, - QuoteEscaped, - Dquoted, - DquoteEscaped, +/// Auto escape for shellwords usage. +pub fn escape(input: Cow) -> Cow { + if !input.chars().any(|x| x.is_ascii_whitespace()) { + input + } else if cfg!(unix) { + Cow::Owned(input.chars().fold(String::new(), |mut buf, c| { + if c.is_ascii_whitespace() { + buf.push('\\'); + } + buf.push(c); + buf + })) + } else { + Cow::Owned(format!("\"{}\"", input)) } +} + +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 = Normal; - 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 { - Normal => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - NormalEscaped - } else { - Normal + 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; - Dquoted - } - '\'' => { - end = i; - Quoted - } - c if c.is_ascii_whitespace() => { - end = i; - Normal - } - _ => Normal, - }, - NormalEscaped => Normal, - Quoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - QuoteEscaped - } else { + '\'' => { + end = i; Quoted } - } - '\'' => { - end = i; - Normal - } - _ => Quoted, - }, - QuoteEscaped => Quoted, - Dquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - DquoteEscaped - } else { - Dquoted + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + OnWhitespace + } } - } - '"' => { - end = i; - Normal - } - _ => Dquoted, - }, - DquoteEscaped => Dquoted, - }; + c if c.is_ascii_whitespace() => { + end = i; + OnWhitespace + } + _ => 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[unescaped_start..i]); + unescaped_start = i + 1; + QuoteEscaped + } else { + Quoted + } + } + '\'' => { + 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)] @@ -114,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"), @@ -132,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"), @@ -149,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"), @@ -164,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"), @@ -179,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"), @@ -195,4 +289,48 @@ mod test { ]; assert_eq!(expected, result); } + + #[test] + fn test_lists() { + let input = + r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#; + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); + let expected = vec![ + Cow::from(":set"), + Cow::from("statusline.center"), + Cow::from(r#"["file-type","file-encoding"]"#), + Cow::from(r#"["list", "in", "qoutes"]"#), + ]; + assert_eq!(expected, result); + } + + #[test] + #[cfg(unix)] + fn test_escaping_unix() { + 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".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/surround.rs b/helix-core/src/surround.rs index 6244b380..a3de3cd1 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -13,7 +13,7 @@ pub const PAIRS: &[(char, char)] = &[ ('(', ')'), ]; -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq)] pub enum Error { PairNotFound, CursorOverlap, diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index e0a984d2..41ab23e1 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -7,14 +7,19 @@ 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::{ borrow::Cow, cell::RefCell, - collections::{HashMap, HashSet, VecDeque}, + collections::{HashMap, VecDeque}, fmt, + hash::{Hash, Hasher}, + mem::{replace, transmute}, path::Path, str::FromStr, sync::Arc, @@ -59,17 +64,23 @@ pub struct Configuration { pub language: Vec, } +impl Default for Configuration { + fn default() -> Self { + crate::config::default_syntax_loader() + } +} + // largely based on tree-sitter/cli/src/loader.rs #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LanguageConfiguration { #[serde(rename = "name")] pub language_id: String, // c-sharp, rust - pub scope: String, // source.rust - pub file_types: Vec, // filename ends_with? + pub scope: String, // source.rust + pub file_types: Vec, // filename extension or ends_with? #[serde(default)] pub shebangs: Vec, // interpreter(s) associated with language - pub roots: Vec, // these indicate project roots <.git, Cargo.toml> + pub roots: Vec, // these indicate project roots <.git, Cargo.toml> pub comment_token: Option, pub max_line_length: Option, @@ -117,6 +128,78 @@ pub struct LanguageConfiguration { pub rulers: Option>, // if set, override editor's rulers } +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum FileType { + /// The extension of the file, either the `Path::extension` or the full + /// filename if the file does not have an extension. + Extension(String), + /// The suffix of a file. This is compared to a given file's absolute + /// path, so it can be used to detect files based on their directories. + Suffix(String), +} + +impl Serialize for FileType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + match self { + FileType::Extension(extension) => serializer.serialize_str(extension), + FileType::Suffix(suffix) => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("suffix", &suffix.replace(std::path::MAIN_SEPARATOR, "/"))?; + map.end() + } + } + } +} + +impl<'de> Deserialize<'de> for FileType { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct FileTypeVisitor; + + impl<'de> serde::de::Visitor<'de> for FileTypeVisitor { + type Value = FileType; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string or table") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(FileType::Extension(value.to_string())) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + match map.next_entry::()? { + Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix( + suffix.replace('/', &std::path::MAIN_SEPARATOR.to_string()), + )), + Some((key, _value)) => Err(serde::de::Error::custom(format!( + "unknown key in `file-types` list: {}", + key + ))), + None => Err(serde::de::Error::custom( + "expected a `suffix` key in the `file-types` entry", + )), + } + } + } + + deserializer.deserialize_any(FileTypeVisitor) + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LanguageServerConfiguration { @@ -124,6 +207,8 @@ pub struct LanguageServerConfiguration { #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub args: Vec, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub environment: HashMap, #[serde(default = "default_timeout")] pub timeout: u64, pub language_id: Option, @@ -138,7 +223,7 @@ pub struct FormatterConfiguration { pub args: Vec, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct AdvancedCompletion { pub name: Option, @@ -146,14 +231,14 @@ pub struct AdvancedCompletion { pub default: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", untagged)] pub enum DebugConfigCompletion { Named(String), Advanced(AdvancedCompletion), } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum DebugArgumentValue { String(String), @@ -161,7 +246,7 @@ pub enum DebugArgumentValue { Boolean(bool), } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct DebugTemplate { pub name: String, @@ -170,7 +255,7 @@ pub struct DebugTemplate { pub args: HashMap, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct DebugAdapterConfig { pub name: String, @@ -186,7 +271,7 @@ pub struct DebugAdapterConfig { } // Different workarounds for adapters' differences -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct DebuggerQuirks { #[serde(default)] pub absolute_paths: bool, @@ -200,7 +285,7 @@ pub struct IndentationConfiguration { } /// Configuration for auto pairs -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] pub enum AutoPairConfig { /// Enables or disables auto pairing. False means disabled. True means to use the default pairs. @@ -274,6 +359,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). @@ -314,6 +419,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, _)| { @@ -353,20 +460,24 @@ pub fn read_query(language: &str, filename: &str) -> String { impl LanguageConfiguration { fn initialize_highlight(&self, scopes: &[String]) -> Option> { - let language = self.language_id.to_ascii_lowercase(); - - let highlights_query = read_query(&language, "highlights.scm"); + let highlights_query = read_query(&self.language_id, "highlights.scm"); // always highlight syntax errors // highlights_query += "\n(ERROR) @error"; - let injections_query = read_query(&language, "injections.scm"); - let locals_query = read_query(&language, "locals.scm"); + let injections_query = read_query(&self.language_id, "injections.scm"); + let locals_query = read_query(&self.language_id, "locals.scm"); if highlights_query.is_empty() { None } else { let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id)) - .map_err(|e| log::info!("{}", e)) + .map_err(|err| { + log::error!( + "Failed to load tree-sitter parser for language {:?}: {}", + self.language_id, + err + ) + }) .ok()?; let config = HighlightConfiguration::new( language, @@ -418,14 +529,20 @@ impl LanguageConfiguration { } fn load_query(&self, kind: &str) -> Option { - let lang_name = self.language_id.to_ascii_lowercase(); - let query_text = read_query(&lang_name, kind); + let query_text = read_query(&self.language_id, kind); if query_text.is_empty() { return None; } let lang = self.highlight_config.get()?.as_ref()?.language; Query::new(lang, &query_text) - .map_err(|e| log::error!("Failed to parse {} queries for {}: {}", kind, lang_name, e)) + .map_err(|e| { + log::error!( + "Failed to parse {} queries for {}: {}", + kind, + self.language_id, + e + ) + }) .ok() } } @@ -436,7 +553,8 @@ impl LanguageConfiguration { pub struct Loader { // highlight_names ? language_configs: Vec>, - language_config_ids_by_file_type: HashMap, // Vec + language_config_ids_by_extension: HashMap, // Vec + language_config_ids_by_suffix: HashMap, language_config_ids_by_shebang: HashMap, scopes: ArcSwap>, @@ -446,7 +564,8 @@ impl Loader { pub fn new(config: Configuration) -> Self { let mut loader = Self { language_configs: Vec::new(), - language_config_ids_by_file_type: HashMap::new(), + language_config_ids_by_extension: HashMap::new(), + language_config_ids_by_suffix: HashMap::new(), language_config_ids_by_shebang: HashMap::new(), scopes: ArcSwap::from_pointee(Vec::new()), }; @@ -457,9 +576,14 @@ impl Loader { for file_type in &config.file_types { // entry().or_insert(Vec::new).push(language_id); - loader - .language_config_ids_by_file_type - .insert(file_type.clone(), language_id); + match file_type { + FileType::Extension(extension) => loader + .language_config_ids_by_extension + .insert(extension.clone(), language_id), + FileType::Suffix(suffix) => loader + .language_config_ids_by_suffix + .insert(suffix.clone(), language_id), + }; } for shebang in &config.shebangs { loader @@ -479,11 +603,22 @@ impl Loader { let configuration_id = path .file_name() .and_then(|n| n.to_str()) - .and_then(|file_name| self.language_config_ids_by_file_type.get(file_name)) + .and_then(|file_name| self.language_config_ids_by_extension.get(file_name)) .or_else(|| { path.extension() .and_then(|extension| extension.to_str()) - .and_then(|extension| self.language_config_ids_by_file_type.get(extension)) + .and_then(|extension| self.language_config_ids_by_extension.get(extension)) + }) + .or_else(|| { + self.language_config_ids_by_suffix + .iter() + .find_map(|(file_type, id)| { + if path.to_str()?.ends_with(file_type) { + Some(id) + } else { + None + } + }) }); configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) @@ -594,6 +729,7 @@ impl Syntax { tree: None, config, depth: 0, + flags: LayerUpdateFlags::empty(), ranges: vec![Range { start_byte: 0, end_byte: usize::MAX, @@ -639,29 +775,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 &mut self.layers.values_mut() { - // The root layer always covers the whole range (0..usize::MAX) - if layer.depth == 0 { - 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() { @@ -689,6 +834,8 @@ impl Syntax { edit.new_end_position, point_sub(range.end_point, edit.old_end_position), ); + + layer.flags |= LayerUpdateFlags::MOVED; } // if the edit starts in the space before and extends into the range else if edit.start_byte < range.start_byte { @@ -703,11 +850,13 @@ impl Syntax { edit.new_end_position, point_sub(range.end_point, edit.old_end_position), ); + layer.flags = LayerUpdateFlags::MODIFIED; } // If the edit is an insertion at the start of the tree, shift else if edit.start_byte == range.start_byte && is_pure_insertion { range.start_byte = edit.new_end_byte; range.start_point = edit.new_end_position; + layer.flags |= LayerUpdateFlags::MOVED; } else { range.end_byte = range .end_byte @@ -717,10 +866,17 @@ impl Syntax { edit.new_end_position, point_sub(range.end_point, edit.old_end_position), ); + layer.flags = LayerUpdateFlags::MODIFIED; } } } } + + 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| { @@ -728,30 +884,37 @@ 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(..); - let mut touched = HashSet::new(); - - // TODO: we should be able to avoid editing & parsing layers with ranges earlier in the document before the edit - while let Some(layer_id) = queue.pop_front() { - // Mark the layer as touched - touched.insert(layer_id); - let layer = &mut self.layers[layer_id]; + // Mark the layer as touched + layer.flags |= LayerUpdateFlags::TOUCHED; + // If a tree already exists, notify it of changes. if let Some(tree) = &mut layer.tree { - for edit in edits.iter().rev() { - // Apply the edits in reverse. - // If we applied them in order then edit 1 would disrupt the positioning of edit 2. - tree.edit(edit); + if layer + .flags + .intersects(LayerUpdateFlags::MODIFIED | LayerUpdateFlags::MOVED) + { + for edit in edits.iter().rev() { + // Apply the edits in reverse. + // If we applied them in order then edit 1 would disrupt the positioning of edit 2. + tree.edit(edit); + } } - } - // Re-parse the tree. - layer.parse(&mut ts_parser.parser, source)?; + if layer.flags.contains(LayerUpdateFlags::MODIFIED) { + // Re-parse the tree. + layer.parse(&mut ts_parser.parser, source)?; + } + } else { + // always parse if this layer has never been parsed before + layer.parse(&mut ts_parser.parser, source)?; + } // Switch to an immutable borrow. let layer = &self.layers[layer_id]; @@ -838,25 +1001,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, - }) - }); + let layer_id = layer.unwrap_or_else(|| self.layers.insert(new_layer)); queue.push_back(layer_id); } @@ -868,8 +1029,11 @@ impl Syntax { // Return the cursor back in the pool. ts_parser.cursors.push(cursor); - // Remove all untouched layers - self.layers.retain(|id, _| touched.contains(&id)); + // Reset all `LayerUpdateFlags` and remove all untouched layers + self.layers.retain(|_, layer| { + replace(&mut layer.flags, LayerUpdateFlags::empty()) + .contains(LayerUpdateFlags::TOUCHED) + }); Ok(()) }) @@ -906,6 +1070,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( @@ -968,6 +1133,16 @@ impl Syntax { // TODO: Folding } +bitflags! { + /// Flags that track the status of a layer + /// in the `Sytaxn::update` function + struct LayerUpdateFlags : u32{ + const MODIFIED = 0b001; + const MOVED = 0b010; + const TOUCHED = 0b100; + } +} + #[derive(Debug)] pub struct LanguageLayer { // mode @@ -975,7 +1150,36 @@ pub struct LanguageLayer { pub config: Arc, pub(crate) tree: Option, pub ranges: Vec, - pub depth: usize, + pub depth: u32, + 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 { @@ -1123,7 +1327,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; @@ -1191,7 +1395,7 @@ struct HighlightIter<'a> { layers: Vec>, iter_count: usize, next_event: Option, - last_highlight_range: Option<(usize, usize, usize)>, + last_highlight_range: Option<(usize, usize, u32)>, } // Adapter to convert rope chunks to bytes @@ -1224,7 +1428,7 @@ struct HighlightIterLayer<'a> { config: &'a HighlightConfiguration, highlight_end_stack: Vec, scope_stack: Vec>, - depth: usize, + depth: u32, ranges: &'a [Range], } @@ -1993,6 +2197,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 { + 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, + cursor: &mut TreeCursor, + depth: usize, +) -> fmt::Result { + let node = cursor.node(); + let visible = node_is_visible(&node); + + if visible { + let indentation_columns = depth * 2; + write!(fmt, "{:indentation_columns$}", "")?; + + if let Some(field_name) = cursor.field_name() { + write!(fmt, "{}: ", field_name)?; + } + + write!(fmt, "({}", node.kind())?; + } + + // Handle children. + if cursor.goto_first_child() { + loop { + if node_is_visible(&cursor.node()) { + fmt.write_char('\n')?; + } + + 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 visible { + fmt.write_char(')')?; + } + + Ok(()) +} + #[cfg(test)] mod test { use super::*; @@ -2013,7 +2279,7 @@ mod test { ); let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language("Rust").unwrap(); + let language = get_language("rust").unwrap(); let query = Query::new(language, query_str).unwrap(); let textobject = TextObjectQuery { query }; @@ -2073,7 +2339,7 @@ mod test { let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language("Rust").unwrap(); + let language = get_language("rust").unwrap(); let config = HighlightConfiguration::new( language, &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm") @@ -2164,6 +2430,93 @@ mod test { ); } + #[track_caller] + 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(language_name).unwrap(); + + let config = HighlightConfiguration::new(language, "", "", "").unwrap(); + let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)); + + let root = syntax + .tree() + .root_node() + .descendant_for_byte_range(start, end) + .unwrap(); + + let mut output = String::new(); + pretty_print_tree(&mut output, root).unwrap(); + + assert_eq!(expected, output); + } + + #[test] + fn test_pretty_print() { + let source = r#"/// Hello"#; + 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", + " name: (identifier)\n", + " parameters: (parameters)\n", + " body: (block\n", + " (expression_statement\n", + " (macro_invocation\n", + " macro: (identifier)\n", + " (token_tree\n", + " (string_literal))))))", + ), + 0, + source.len(), + ); + + // Selecting a token should print just that token: + let source = r#"fn main() {}"#; + assert_pretty_print("rust", source, r#""fn""#, 0, 1); + + // Error nodes are printed as errors: + let source = r#"}{"#; + 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] fn test_load_runtime_file() { // Test to make sure we can load some data from the runtime directory. diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs index 45503107..17523ed7 100644 --- a/helix-core/src/test.rs +++ b/helix-core/src/test.rs @@ -34,7 +34,7 @@ pub fn print(s: &str) -> (String, Selection) { let mut left = String::with_capacity(s.len()); 'outer: while let Some(c) = iter.next() { - let start = left.len(); + let start = left.chars().count(); if c != '#' { left.push(c); @@ -63,6 +63,7 @@ pub fn print(s: &str) -> (String, Selection) { left.push(c); continue; } + if !head_at_beg { let prev = left.pop().unwrap(); if prev != '|' { @@ -71,15 +72,18 @@ pub fn print(s: &str) -> (String, Selection) { continue; } } + iter.next(); // skip "#" if is_primary { primary_idx = Some(ranges.len()); } + let (anchor, head) = match head_at_beg { - true => (left.len(), start), - false => (start, left.len()), + true => (left.chars().count(), start), + false => (start, left.chars().count()), }; + ranges.push(Range::new(anchor, head)); continue 'outer; } @@ -95,6 +99,7 @@ pub fn print(s: &str) -> (String, Selection) { Some(i) => i, None => panic!("missing primary `#[|]#` {:?}", s), }; + let selection = Selection::new(ranges, primary); (left, selection) } @@ -141,3 +146,120 @@ pub fn plain(s: &str, selection: Selection) -> String { } out } + +#[cfg(test)] +#[allow(clippy::module_inception)] +mod test { + use super::*; + + #[test] + fn print_single() { + assert_eq!( + (String::from("hello"), Selection::single(1, 0)), + print("#[|h]#ello") + ); + assert_eq!( + (String::from("hello"), Selection::single(0, 1)), + print("#[h|]#ello") + ); + assert_eq!( + (String::from("hello"), Selection::single(4, 0)), + print("#[|hell]#o") + ); + assert_eq!( + (String::from("hello"), Selection::single(0, 4)), + print("#[hell|]#o") + ); + assert_eq!( + (String::from("hello"), Selection::single(5, 0)), + print("#[|hello]#") + ); + assert_eq!( + (String::from("hello"), Selection::single(0, 5)), + print("#[hello|]#") + ); + } + + #[test] + fn print_multi() { + assert_eq!( + ( + String::from("hello"), + Selection::new( + SmallVec::from_slice(&[Range::new(1, 0), Range::new(5, 4)]), + 0 + ) + ), + print("#[|h]#ell#(|o)#") + ); + assert_eq!( + ( + String::from("hello"), + Selection::new( + SmallVec::from_slice(&[Range::new(0, 1), Range::new(4, 5)]), + 0 + ) + ), + print("#[h|]#ell#(o|)#") + ); + assert_eq!( + ( + String::from("hello"), + Selection::new( + SmallVec::from_slice(&[Range::new(2, 0), Range::new(5, 3)]), + 0 + ) + ), + print("#[|he]#l#(|lo)#") + ); + assert_eq!( + ( + String::from("hello\r\nhello\r\nhello\r\n"), + Selection::new( + SmallVec::from_slice(&[ + Range::new(7, 5), + Range::new(21, 19), + Range::new(14, 12) + ]), + 0 + ) + ), + print("hello#[|\r\n]#hello#(|\r\n)#hello#(|\r\n)#") + ); + } + + #[test] + fn print_multi_byte_code_point() { + assert_eq!( + (String::from("„“"), Selection::single(1, 0)), + print("#[|„]#“") + ); + assert_eq!( + (String::from("„“"), Selection::single(2, 1)), + print("„#[|“]#") + ); + assert_eq!( + (String::from("„“"), Selection::single(0, 1)), + print("#[„|]#“") + ); + assert_eq!( + (String::from("„“"), Selection::single(1, 2)), + print("„#[“|]#") + ); + assert_eq!( + (String::from("they said „hello“"), Selection::single(11, 10)), + print("they said #[|„]#hello“") + ); + } + + #[test] + fn print_multi_code_point_grapheme() { + assert_eq!( + ( + String::from("hello 👨‍👩‍👧‍👦 goodbye"), + Selection::single(13, 6) + ), + print("hello #[|👨‍👩‍👧‍👦]# goodbye") + ); + } +} diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index daf4a77e..482fd6d9 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -56,7 +56,7 @@ impl ChangeSet { } // Changeset builder operations: delete/insert/retain - fn delete(&mut self, n: usize) { + pub(crate) fn delete(&mut self, n: usize) { use Operation::*; if n == 0 { return; @@ -71,7 +71,7 @@ impl ChangeSet { } } - fn insert(&mut self, fragment: Tendril) { + pub(crate) fn insert(&mut self, fragment: Tendril) { use Operation::*; if fragment.is_empty() { @@ -93,7 +93,7 @@ impl ChangeSet { self.changes.push(new_last); } - fn retain(&mut self, n: usize) { + pub(crate) fn retain(&mut self, n: usize) { use Operation::*; if n == 0 { return; @@ -577,7 +577,7 @@ impl<'a> Iterator for ChangeIterator<'a> { #[cfg(test)] mod test { use super::*; - use crate::State; + use crate::history::State; #[test] fn composition() { @@ -704,7 +704,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-core/tests/indent.rs b/helix-core/tests/indent.rs index ff04d05f..e1114f4a 100644 --- a/helix-core/tests/indent.rs +++ b/helix-core/tests/indent.rs @@ -50,6 +50,7 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) { indent_query, &syntax, &IndentStyle::Spaces(4), + 4, text, i, text.line_to_char(i) + pos, diff --git a/helix-dap/src/transport.rs b/helix-dap/src/transport.rs index 783a6f5d..dd03e568 100644 --- a/helix-dap/src/transport.rs +++ b/helix-dap/src/transport.rs @@ -22,7 +22,7 @@ pub struct Request { pub arguments: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] pub struct Response { // seq is omitted as unused and is not sent by some implementations pub request_seq: u64, diff --git a/helix-dap/src/types.rs b/helix-dap/src/types.rs index 45f45cca..0a9ebe5e 100644 --- a/helix-dap/src/types.rs +++ b/helix-dap/src/types.rs @@ -22,7 +22,7 @@ pub trait Request { const COMMAND: &'static str; } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ColumnDescriptor { pub attribute_name: String, @@ -35,7 +35,7 @@ pub struct ColumnDescriptor { pub width: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExceptionBreakpointsFilter { pub filter: String, @@ -50,7 +50,7 @@ pub struct ExceptionBreakpointsFilter { pub condition_description: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DebuggerCapabilities { #[serde(skip_serializing_if = "Option::is_none")] @@ -131,14 +131,14 @@ pub struct DebuggerCapabilities { pub supported_checksum_algorithms: Option>, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Checksum { pub algorithm: String, pub checksum: String, } -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Source { #[serde(skip_serializing_if = "Option::is_none")] @@ -159,7 +159,7 @@ pub struct Source { pub checksums: Option>, } -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceBreakpoint { pub line: usize, @@ -173,7 +173,7 @@ pub struct SourceBreakpoint { pub log_message: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Breakpoint { #[serde(skip_serializing_if = "Option::is_none")] @@ -197,7 +197,7 @@ pub struct Breakpoint { pub offset: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StackFrameFormat { #[serde(skip_serializing_if = "Option::is_none")] @@ -216,7 +216,7 @@ pub struct StackFrameFormat { pub include_all: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StackFrame { pub id: usize, @@ -239,14 +239,14 @@ pub struct StackFrame { pub presentation_hint: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Thread { pub id: ThreadId, pub name: String, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Scope { pub name: String, @@ -270,14 +270,14 @@ pub struct Scope { pub end_column: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ValueFormat { #[serde(skip_serializing_if = "Option::is_none")] pub hex: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct VariablePresentationHint { #[serde(skip_serializing_if = "Option::is_none")] @@ -288,7 +288,7 @@ pub struct VariablePresentationHint { pub visibility: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Variable { pub name: String, @@ -308,7 +308,7 @@ pub struct Variable { pub memory_reference: Option, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Module { pub id: String, // TODO: || number @@ -333,7 +333,7 @@ pub struct Module { pub mod requests { use super::*; - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct InitializeArguments { #[serde(rename = "clientID", skip_serializing_if = "Option::is_none")] @@ -409,7 +409,7 @@ pub mod requests { const COMMAND: &'static str = "configurationDone"; } - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetBreakpointsArguments { pub source: Source, @@ -420,7 +420,7 @@ pub mod requests { pub source_modified: Option, } - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetBreakpointsResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -436,13 +436,13 @@ pub mod requests { const COMMAND: &'static str = "setBreakpoints"; } - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ContinueArguments { pub thread_id: ThreadId, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ContinueResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -458,7 +458,7 @@ pub mod requests { const COMMAND: &'static str = "continue"; } - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StackTraceArguments { pub thread_id: ThreadId, @@ -470,7 +470,7 @@ pub mod requests { pub format: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StackTraceResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -487,7 +487,7 @@ pub mod requests { const COMMAND: &'static str = "stackTrace"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThreadsResponse { pub threads: Vec, @@ -502,13 +502,13 @@ pub mod requests { const COMMAND: &'static str = "threads"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScopesArguments { pub frame_id: usize, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ScopesResponse { pub scopes: Vec, @@ -523,7 +523,7 @@ pub mod requests { const COMMAND: &'static str = "scopes"; } - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct VariablesArguments { pub variables_reference: usize, @@ -537,7 +537,7 @@ pub mod requests { pub format: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct VariablesResponse { pub variables: Vec, @@ -552,7 +552,7 @@ pub mod requests { const COMMAND: &'static str = "variables"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StepInArguments { pub thread_id: ThreadId, @@ -571,7 +571,7 @@ pub mod requests { const COMMAND: &'static str = "stepIn"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StepOutArguments { pub thread_id: ThreadId, @@ -588,7 +588,7 @@ pub mod requests { const COMMAND: &'static str = "stepOut"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct NextArguments { pub thread_id: ThreadId, @@ -605,7 +605,7 @@ pub mod requests { const COMMAND: &'static str = "next"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PauseArguments { pub thread_id: ThreadId, @@ -620,7 +620,7 @@ pub mod requests { const COMMAND: &'static str = "pause"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EvaluateArguments { pub expression: String, @@ -632,7 +632,7 @@ pub mod requests { pub format: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EvaluateResponse { pub result: String, @@ -658,7 +658,7 @@ pub mod requests { const COMMAND: &'static str = "evaluate"; } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetExceptionBreakpointsArguments { pub filters: Vec, @@ -666,7 +666,7 @@ pub mod requests { // pub exceptionOptions: Option>, // needs capability } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetExceptionBreakpointsResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -684,7 +684,7 @@ pub mod requests { // Reverse Requests - #[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RunInTerminalResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -693,7 +693,7 @@ pub mod requests { pub shell_process_id: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RunInTerminalArguments { #[serde(skip_serializing_if = "Option::is_none")] @@ -726,7 +726,7 @@ pub mod events { #[serde(tag = "event", content = "body")] // seq is omitted as unused and is not sent by some implementations pub enum Event { - Initialized, + Initialized(Option), Stopped(Stopped), Continued(Continued), Exited(Exited), @@ -745,7 +745,7 @@ pub mod events { Memory(Memory), } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Stopped { pub reason: String, @@ -763,7 +763,7 @@ pub mod events { pub hit_breakpoint_ids: Option>, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Continued { pub thread_id: ThreadId, @@ -771,27 +771,27 @@ pub mod events { pub all_threads_continued: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Exited { pub exit_code: usize, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Terminated { #[serde(skip_serializing_if = "Option::is_none")] pub restart: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Thread { pub reason: String, pub thread_id: ThreadId, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Output { pub output: String, @@ -811,28 +811,28 @@ pub mod events { pub data: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Breakpoint { pub reason: String, pub breakpoint: super::Breakpoint, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Module { pub reason: String, pub module: super::Module, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LoadedSource { pub reason: String, pub source: super::Source, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Process { pub name: String, @@ -846,13 +846,13 @@ pub mod events { pub pointer_size: Option, } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Capabilities { pub capabilities: super::DebuggerCapabilities, } - // #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + // #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] // #[serde(rename_all = "camelCase")] // pub struct Invalidated { // pub areas: Vec, @@ -860,7 +860,7 @@ pub mod events { // pub stack_frame_id: Option, // } - #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] + #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Memory { pub memory_reference: String, diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index e23e0290..760205e1 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -19,7 +19,7 @@ serde = { version = "1.0", features = ["derive"] } toml = "0.5" etcetera = "0.4" tree-sitter = "0.20" -once_cell = "1.14" +once_cell = "1.16" log = "0.4" # TODO: these two should be on !wasm32 only diff --git a/helix-loader/build.rs b/helix-loader/build.rs index e0ebd1c4..c4b89e6b 100644 --- a/helix-loader/build.rs +++ b/helix-loader/build.rs @@ -1,6 +1,26 @@ +use std::borrow::Cow; +use std::process::Command; + +const VERSION: &str = include_str!("../VERSION"); + fn main() { + let git_hash = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|x| String::from_utf8(x.stdout).ok()); + + let version: Cow<_> = match git_hash { + Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(), + None => VERSION.into(), + }; + println!( "cargo:rustc-env=BUILD_TARGET={}", std::env::var("TARGET").unwrap() ); + + println!("cargo:rerun-if-changed=../VERSION"); + println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version); } diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index eb1895a5..2aa92475 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -67,8 +67,7 @@ pub fn get_language(name: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn get_language(name: &str) -> Result { use libloading::{Library, Symbol}; - let name = name.to_ascii_lowercase(); - let mut library_path = crate::runtime_dir().join("grammars").join(&name); + let mut library_path = crate::runtime_dir().join("grammars").join(name); library_path.set_extension(DYLIB_EXTENSION); let library = unsafe { Library::new(&library_path) } @@ -264,7 +263,7 @@ fn fetch_grammar(grammar: GrammarConfiguration) -> Result { ))?; // create the grammar dir contains a git directory - if !grammar_dir.join(".git").is_dir() { + if !grammar_dir.join(".git").exists() { git(&grammar_dir, ["init"])?; } @@ -430,7 +429,7 @@ fn build_tree_sitter_library( if cfg!(all(windows, target_env = "msvc")) { command - .args(&["/nologo", "/LD", "/I"]) + .args(["/nologo", "/LD", "/I"]) .arg(header_path) .arg("/Od") .arg("/utf-8"); diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 3c9905f5..80d44a82 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -4,6 +4,8 @@ pub mod grammar; use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; use std::path::PathBuf; +pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH"); + pub static RUNTIME_DIR: once_cell::sync::Lazy = once_cell::sync::Lazy::new(runtime_dir); static CONFIG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); @@ -59,7 +61,7 @@ pub fn config_dir() -> PathBuf { } pub fn local_config_dirs() -> Vec { - let directories = find_root_impl(None, &[".helix".to_string()]) + let directories = find_local_config_dirs() .into_iter() .map(|path| path.join(".helix")) .collect(); @@ -90,32 +92,16 @@ pub fn log_file() -> PathBuf { cache_dir().join("helix.log") } -pub fn find_root_impl(root: Option<&str>, root_markers: &[String]) -> Vec { +pub fn find_local_config_dirs() -> Vec { let current_dir = std::env::current_dir().expect("unable to determine current directory"); let mut directories = Vec::new(); - let root = match root { - Some(root) => { - let root = std::path::Path::new(root); - if root.is_absolute() { - root.to_path_buf() - } else { - current_dir.join(root) - } - } - None => current_dir, - }; - - for ancestor in root.ancestors() { - // don't go higher than repo - if ancestor.join(".git").is_dir() { - // Use workspace if detected from marker + for ancestor in current_dir.ancestors() { + if ancestor.join(".git").exists() { directories.push(ancestor.to_path_buf()); + // Don't go higher than repo if we're in one break; - } else if root_markers - .iter() - .any(|marker| ancestor.join(marker).exists()) - { + } else if ancestor.join(".helix").is_dir() { directories.push(ancestor.to_path_buf()); } } diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 2e5b8139..d04edcd5 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -13,6 +13,7 @@ homepage = "https://helix-editor.com" [dependencies] helix-core = { version = "0.6", path = "../helix-core" } +helix-loader = { version = "0.6", path = "../helix-loader" } anyhow = "1.0" futures-executor = "0.3" @@ -22,6 +23,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-stream = "0.1.9" +tokio = { version = "1.23", 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 9ae8f20e..dd2581c6 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -4,8 +4,8 @@ use crate::{ Call, Error, OffsetEncoding, Result, }; -use anyhow::anyhow; use helix_core::{find_root, ChangeSet, Rope}; +use helix_loader::{self, VERSION_AND_GIT_HASH}; use lsp_types as lsp; use serde::Deserialize; use serde_json::Value; @@ -34,7 +34,7 @@ pub struct Client { pub(crate) capabilities: OnceCell, offset_encoding: OffsetEncoding, config: Option, - root_path: Option, + root_path: std::path::PathBuf, root_uri: Option, workspace_folders: Vec, req_timeout: u64, @@ -42,18 +42,22 @@ pub struct Client { impl Client { #[allow(clippy::type_complexity)] + #[allow(clippy::too_many_arguments)] pub fn start( cmd: &str, args: &[String], config: Option, + server_environment: HashMap, root_markers: &[String], id: usize, req_timeout: u64, + doc_path: Option<&std::path::PathBuf>, ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { // Resolve path to the binary let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?; let process = Command::new(cmd) + .envs(server_environment) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -72,11 +76,12 @@ impl Client { let (server_rx, server_tx, initialize_notify) = Transport::start(reader, writer, stderr, id); - let root_path = find_root(None, root_markers); + let root_path = find_root( + doc_path.and_then(|x| x.parent().and_then(|x| x.to_str())), + root_markers, + ); - let root_uri = root_path - .clone() - .and_then(|root| lsp::Url::from_file_path(root).ok()); + let root_uri = lsp::Url::from_file_path(root_path.clone()).ok(); // TODO: support multiple workspace folders let workspace_folders = root_uri @@ -281,10 +286,7 @@ impl Client { workspace_folders: Some(self.workspace_folders.clone()), // root_path is obsolete, but some clients like pyright still use it so we specify both. // clients will prefer _uri if possible - root_path: self - .root_path - .clone() - .and_then(|path| path.to_str().map(|path| path.to_owned())), + root_path: self.root_path.to_str().map(|path| path.to_owned()), root_uri: self.root_uri.clone(), initialization_options: self.config.clone(), capabilities: lsp::ClientCapabilities { @@ -299,6 +301,9 @@ impl Client { dynamic_registration: Some(false), ..Default::default() }), + execute_command: Some(lsp::DynamicRegistrationClientCapabilities { + dynamic_registration: Some(false), + }), ..Default::default() }), text_document: Some(lsp::TextDocumentClientCapabilities { @@ -312,6 +317,7 @@ impl Client { String::from("additionalTextEdits"), ], }), + insert_replace_support: Some(true), ..Default::default() }), completion_item_kind: Some(lsp::CompletionItemKindCapability { @@ -374,7 +380,10 @@ impl Client { ..Default::default() }, trace: None, - client_info: None, + client_info: Some(lsp::ClientInfo { + name: String::from("helix"), + version: Some(String::from(VERSION_AND_GIT_HASH)), + }), locale: None, // TODO }; @@ -543,16 +552,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, }; @@ -628,8 +638,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, @@ -644,15 +658,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( @@ -663,7 +687,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 { @@ -684,7 +708,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, @@ -694,7 +729,7 @@ impl Client { // lsp::SignatureHelpContext }; - self.call::(params) + Some(self.call::(params)) } // formatting @@ -707,13 +742,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 @@ -748,22 +781,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, @@ -772,11 +803,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( @@ -784,7 +817,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, @@ -796,7 +837,7 @@ impl Client { }, }; - self.call::(params) + Some(self.call::(params)) } fn goto_request< @@ -829,8 +870,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( @@ -838,12 +891,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( @@ -851,12 +915,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( @@ -864,7 +939,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, @@ -879,31 +962,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( @@ -911,7 +1010,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, @@ -920,26 +1030,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 { @@ -953,11 +1059,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(), @@ -966,6 +1082,6 @@ impl Client { }, }; - self.call::(params) + Some(self.call::(params)) } } diff --git a/helix-lsp/src/jsonrpc.rs b/helix-lsp/src/jsonrpc.rs index b9b3fd2c..75ac9309 100644 --- a/helix-lsp/src/jsonrpc.rs +++ b/helix-lsp/src/jsonrpc.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; // https://www.jsonrpc.org/specification#error_object -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum ErrorCode { ParseError, InvalidRequest, @@ -68,7 +68,7 @@ impl Serialize for ErrorCode { } } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct Error { pub code: ErrorCode, pub message: String, @@ -100,7 +100,7 @@ impl std::error::Error for Error {} // https://www.jsonrpc.org/specification#request_object /// Request ID -#[derive(Debug, PartialEq, Clone, Hash, Eq, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, Deserialize, Serialize)] #[serde(untagged)] pub enum Id { Null, @@ -109,7 +109,7 @@ pub enum Id { } /// Protocol Version -#[derive(Debug, PartialEq, Clone, Copy, Hash, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum Version { V2, } @@ -153,7 +153,7 @@ impl<'de> Deserialize<'de> for Version { } } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum Params { None, @@ -182,7 +182,7 @@ impl From for Value { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct MethodCall { pub jsonrpc: Option, @@ -192,7 +192,7 @@ pub struct MethodCall { pub id: Id, } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Notification { pub jsonrpc: Option, @@ -201,7 +201,7 @@ pub struct Notification { pub params: Params, } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] #[serde(untagged)] pub enum Call { @@ -235,7 +235,7 @@ impl From for Call { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] #[serde(untagged)] pub enum Request { @@ -245,7 +245,7 @@ pub enum Request { // https://www.jsonrpc.org/specification#response_object -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct Success { #[serde(skip_serializing_if = "Option::is_none")] pub jsonrpc: Option, @@ -253,7 +253,7 @@ pub struct Success { pub id: Id, } -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] pub struct Failure { #[serde(skip_serializing_if = "Option::is_none")] pub jsonrpc: Option, @@ -264,7 +264,7 @@ pub struct Failure { // Note that failure comes first because we're not using // #[serde(deny_unknown_field)]: we want a request that contains // both `result` and `error` to be a `Failure`. -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum Output { Failure(Failure), @@ -280,7 +280,7 @@ impl From for Result { } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub enum Response { Single(Output), diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index a39325fa..8418896c 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -9,7 +9,8 @@ pub use lsp::{Position, Url}; pub use lsp_types as lsp; use futures_util::stream::select_all::SelectAll; -use helix_core::syntax::LanguageConfiguration; +use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration}; +use tokio::sync::mpsc::UnboundedReceiver; use std::{ collections::{hash_map::Entry, HashMap}, @@ -56,7 +57,7 @@ pub enum OffsetEncoding { pub mod util { use super::*; - use helix_core::{diagnostic::NumberOrString, Range, Rope, Transaction}; + use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction}; /// Converts a diagnostic in the document to [`lsp::Diagnostic`]. /// @@ -84,16 +85,34 @@ pub mod util { None => None, }; - // TODO: add support for Diagnostic.data - lsp::Diagnostic::new( - range_to_lsp_range(doc, range, offset_encoding), + 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 + }; + + lsp::Diagnostic { + range: range_to_lsp_range(doc, range, offset_encoding), severity, code, - None, - diag.message.to_owned(), - None, - None, - ) + source: diag.source.clone(), + message: diag.message.to_owned(), + related_information: None, + tags, + data: diag.data.to_owned(), + ..Default::default() + } } /// Converts [`lsp::Position`] to a position in the document. @@ -177,6 +196,42 @@ pub mod util { Some(Range::new(start, end)) } + /// Creates a [Transaction] from the [lsp::TextEdit] in a completion response. + /// The transaction applies the edit to all cursors. + pub fn generate_transaction_from_completion_edit( + doc: &Rope, + selection: &Selection, + edit: lsp::TextEdit, + offset_encoding: OffsetEncoding, + ) -> Transaction { + let replacement: Option = if edit.new_text.is_empty() { + None + } else { + Some(edit.new_text.into()) + }; + + let text = doc.slice(..); + let primary_cursor = selection.primary().cursor(text); + + let start_offset = match lsp_pos_to_pos(doc, edit.range.start, offset_encoding) { + Some(start) => start as i128 - primary_cursor as i128, + None => return Transaction::new(doc), + }; + let end_offset = match lsp_pos_to_pos(doc, edit.range.end, offset_encoding) { + Some(end) => end as i128 - primary_cursor as i128, + None => return Transaction::new(doc), + }; + + Transaction::change_by_selection(doc, selection, |range| { + let cursor = range.cursor(text); + ( + (cursor as i128 + start_offset) as usize, + (cursor as i128 + end_offset) as usize, + replacement.clone(), + ) + }) + } + pub fn generate_transaction_from_edits( doc: &Rope, mut edits: Vec, @@ -186,6 +241,20 @@ pub mod util { // 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| { @@ -250,6 +319,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), @@ -262,6 +333,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) @@ -318,55 +390,63 @@ impl Registry { .map(|(_, client)| client.as_ref()) } - pub fn get(&mut self, language_config: &LanguageConfiguration) -> Result>> { + 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), }; - 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); - let (client, incoming, initialize_notify) = Client::start( - &config.command, - &config.args, - language_config.config.clone(), - &language_config.roots, - id, - config.timeout, - )?; + + let NewClientResult(client, incoming) = + start_client(id, language_config, config, doc_path)?; self.incoming.push(UnboundedReceiverStream::new(incoming)); - let client = Arc::new(client); - // Initialize the client asynchronously - let _client = client.clone(); + let (_, old_client) = entry.insert((id, 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(); + let _ = old_client.force_shutdown().await; }); + Ok(Some(client)) + } + } + } + + 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)) } @@ -456,6 +536,59 @@ impl LspProgressMap { } } +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(), + ls_config.environment.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}; 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/Cargo.toml b/helix-term/Cargo.toml index 0ebcb24f..9f2e5188 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -17,8 +17,10 @@ build = true app = true [features] +default = ["git"] unicode-lines = ["helix-core/unicode-lines"] integration = [] +git = ["helix-vcs/git"] [[bin]] name = "hx" @@ -29,10 +31,11 @@ helix-core = { version = "0.6", path = "../helix-core" } helix-view = { version = "0.6", path = "../helix-view" } helix-lsp = { version = "0.6", path = "../helix-lsp" } helix-dap = { version = "0.6", path = "../helix-dap" } +helix-vcs = { version = "0.6", path = "../helix-vcs" } helix-loader = { version = "0.6", path = "../helix-loader" } anyhow = "1" -once_cell = "1.14" +once_cell = "1.16" which = "4.2" @@ -67,9 +70,6 @@ serde = { version = "1.0", features = ["derive"] } grep-regex = "0.1.10" grep-searcher = "0.1.10" -# Remove once retain_mut lands in stable rust -retain_mut = "0.1.7" - [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } @@ -77,6 +77,6 @@ signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } helix-loader = { version = "0.6", path = "../helix-loader" } [dev-dependencies] -smallvec = "1.9" -indoc = "1.0.6" +smallvec = "1.10" +indoc = "1.0.8" tempfile = "3.3.0" diff --git a/helix-term/build.rs b/helix-term/build.rs index 74c35a3a..b47dae8e 100644 --- a/helix-term/build.rs +++ b/helix-term/build.rs @@ -1,30 +1,9 @@ use helix_loader::grammar::{build_grammars, fetch_grammars}; -use std::borrow::Cow; -use std::process::Command; - -const VERSION: &str = include_str!("../VERSION"); fn main() { - let git_hash = Command::new("git") - .args(&["rev-parse", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|x| String::from_utf8(x.stdout).ok()); - - let version: Cow<_> = match git_hash { - Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(), - None => VERSION.into(), - }; - if std::env::var("HELIX_DISABLE_AUTO_GRAMMAR_BUILD").is_err() { fetch_grammars().expect("Failed to fetch tree-sitter grammars"); build_grammars(Some(std::env::var("TARGET").unwrap())) .expect("Failed to compile tree-sitter grammars"); } - - println!("cargo:rerun-if-changed=../runtime/grammars/"); - println!("cargo:rerun-if-changed=../VERSION"); - - println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version); } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 7ee5b7f1..5f013b9a 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,13 +1,22 @@ use arc_swap::{access::Map, ArcSwap}; use futures_util::Stream; use helix_core::{ - config::{default_syntax_loader, user_syntax_loader}, - diagnostic::NumberOrString, + diagnostic::{DiagnosticTag, NumberOrString}, + path::get_relative_path, pos_at_coords, syntax, Selection, }; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; -use helix_view::{align_view, editor::ConfigEvent, theme, tree::Layout, Align, Editor}; +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, @@ -19,7 +28,7 @@ use crate::{ ui::{self, overlay::overlayed}, }; -use log::{error, warn}; +use log::{debug, error, warn}; use std::{ io::{stdin, stdout, Write}, sync::Arc, @@ -30,8 +39,8 @@ use anyhow::{Context, Error}; use crossterm::{ event::{ - DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CrosstermEvent, + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent, }, execute, terminal, tty::IsTty, @@ -46,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>, @@ -95,6 +117,7 @@ fn restore_term() -> Result<(), Error> { execute!( stdout, DisableBracketedPaste, + DisableFocusChange, terminal::LeaveAlternateScreen )?; terminal::disable_raw_mode()?; @@ -102,7 +125,11 @@ fn restore_term() -> Result<(), Error> { } impl Application { - pub fn new(args: Args, config: Config) -> Result { + pub fn new( + args: Args, + config: Config, + syn_loader_conf: syntax::Configuration, + ) -> Result { #[cfg(feature = "integration")] setup_integration_logging(); @@ -129,20 +156,20 @@ impl Application { }) .unwrap_or_else(|| theme_loader.default_theme(true_color)); - let syn_loader_conf = user_syntax_loader().unwrap_or_else(|err| { - eprintln!("Bad language config: {}", err); - eprintln!("Press to continue with default language config"); - use std::io::Read; - // This waits for an enter press. - let _ = std::io::stdin().read(&mut []); - default_syntax_loader() - }); 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| { @@ -164,7 +191,7 @@ impl Application { } else if !args.files.is_empty() { let first = &args.files[0].0; // we know it's not empty if first.is_dir() { - std::env::set_current_dir(&first).context("set current dir")?; + std::env::set_current_dir(first).context("set current dir")?; editor.new_file(Action::VerticalSplit); let picker = ui::file_picker(".".into(), &config.load().editor); compositor.push(Box::new(overlayed(picker))); @@ -200,7 +227,11 @@ impl Application { doc.set_selection(view_id, pos); } } - editor.set_status(format!("Loaded {} files.", nr_of_files)); + editor.set_status(format!( + "Loaded {} file{}.", + nr_of_files, + if nr_of_files == 1 { "" } else { "s" } // avoid "Loaded 1 files." grammo + )); // align the view to center after all files are loaded, // does not affect views without pos since it is at the top let (view, doc) = current!(editor); @@ -224,11 +255,12 @@ impl Application { #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] - let signals = - Signals::new(&[signal::SIGTSTP, signal::SIGCONT]).context("build signal handler")?; + let signals = Signals::new([signal::SIGTSTP, signal::SIGCONT, signal::SIGUSR1]) + .context("build signal handler")?; let app = Self { compositor, + terminal, editor, config, @@ -245,23 +277,49 @@ impl Application { Ok(app) } - fn render(&mut self) { - let compositor = &mut self.compositor; + #[cfg(feature = "integration")] + async fn render(&mut self) {} + #[cfg(not(feature = "integration"))] + async fn render(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - compositor.render(&mut cx); + // Acquire mutable access to the redraw_handle lock + // to ensure that there are no tasks running that want to block rendering + drop(cx.editor.redraw_handle.1.write().await); + cx.editor.needs_redraw = false; + { + // exhaust any leftover redraw notifications + let notify = cx.editor.redraw_handle.0.notified(); + tokio::pin!(notify); + notify.enable(); + } + + 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) where S: Stream> + Unpin, { - self.render(); + self.render().await; self.last_render = Instant::now(); loop { @@ -275,9 +333,6 @@ impl Application { where S: Stream> + Unpin, { - #[cfg(feature = "integration")] - let mut idle_handled = false; - loop { if self.editor.should_close() { return false; @@ -289,59 +344,35 @@ impl Application { biased; Some(event) = input_stream.next() => { - self.handle_terminal_events(event); + self.handle_terminal_events(event).await; } Some(signal) = self.signals.next() => { self.handle_signals(signal).await; } - Some((id, call)) = self.editor.language_servers.incoming.next() => { - self.handle_language_server_message(call, id).await; - // limit render calls for fast language server messages - let last = self.editor.language_servers.incoming.is_empty(); - - if last || self.last_render.elapsed() > LSP_DEADLINE { - self.render(); - self.last_render = Instant::now(); - } - } - Some(payload) = self.editor.debugger_events.next() => { - let needs_render = self.editor.handle_debugger_message(payload).await; - if needs_render { - self.render(); - } - } - Some(config_event) = self.editor.config_events.1.recv() => { - self.handle_config_events(config_event); - self.render(); - } Some(callback) = self.jobs.futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); - self.render(); + self.render().await; } Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); - self.render(); + self.render().await; } - _ = &mut self.editor.idle_timer => { - // idle timeout - self.editor.clear_idle_timer(); - self.handle_idle_timeout(); + event = self.editor.wait_event() => { + let _idle_handled = self.handle_editor_event(event).await; #[cfg(feature = "integration")] { - idle_handled = true; + if _idle_handled { + return true; + } } } } // for integration tests only, reset the idle timer after every - // event to make a signal when test events are done processing + // event to signal when test events are done processing #[cfg(feature = "integration")] { - if idle_handled { - return true; - } - self.editor.reset_idle_timer(); } } @@ -411,43 +442,155 @@ 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(); - self.render(); + 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().await; + } + signal::SIGUSR1 => { + self.refresh_config(); + self.render().await; } _ => unreachable!(), } } - pub fn handle_idle_timeout(&mut self) { - use crate::compositor::EventResult; - let editor_view = self - .compositor - .find::() - .expect("expected at least one EditorView"); - + pub async fn handle_idle_timeout(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { - self.render(); + let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx); + if should_render || self.editor.needs_redraw { + self.render().await; } } - pub fn handle_terminal_events(&mut self, event: Result) { + pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) { + let doc_save_event = match doc_save_event { + Ok(event) => event, + Err(err) => { + self.editor.set_error(err.to_string()); + return; + } + }; + + let doc = match self.editor.document_mut(doc_save_event.doc_id) { + None => { + warn!( + "received document saved event for non-existent doc id: {}", + doc_save_event.doc_id + ); + + return; + } + Some(doc) => doc, + }; + + debug!( + "document {:?} saved with revision {}", + doc.path(), + doc_save_event.revision + ); + + doc.set_last_saved_revision(doc_save_event.revision); + + let lines = doc_save_event.text.len_lines(); + let bytes = doc_save_event.text.len_bytes(); + + if doc.path() != Some(&doc_save_event.path) { + if let Err(err) = doc.set_path(Some(&doc_save_event.path)) { + log::error!( + "error setting path for doc '{:?}': {}", + doc.path(), + err.to_string(), + ); + + self.editor.set_error(err.to_string()); + return; + } + + let loader = self.editor.syn_loader.clone(); + + // borrowing the same doc again to get around the borrow checker + let doc = doc_mut!(self.editor, &doc_save_event.doc_id); + let id = doc.id(); + doc.detect_language(loader); + let _ = self.editor.refresh_language_server(id); + } + + // TODO: fix being overwritten by lsp + self.editor.set_status(format!( + "'{}' written, {}L {}B", + get_relative_path(&doc_save_event.path).to_string_lossy(), + lines, + bytes + )); + } + + #[inline(always)] + pub async fn handle_editor_event(&mut self, event: EditorEvent) -> bool { + log::debug!("received editor event: {:?}", event); + + match event { + EditorEvent::DocumentSaved(event) => { + self.handle_document_write(event); + self.render().await; + } + EditorEvent::ConfigEvent(event) => { + self.handle_config_events(event); + self.render().await; + } + EditorEvent::LanguageServerMessage((id, call)) => { + self.handle_language_server_message(call, id).await; + // limit render calls for fast language server messages + let last = self.editor.language_servers.incoming.is_empty(); + + if last || self.last_render.elapsed() > LSP_DEADLINE { + self.render().await; + self.last_render = Instant::now(); + } + } + EditorEvent::DebuggerEvent(payload) => { + let needs_render = self.editor.handle_debugger_message(payload).await; + if needs_render { + self.render().await; + } + } + EditorEvent::IdleTimer => { + self.editor.clear_idle_timer(); + self.handle_idle_timeout().await; + + #[cfg(feature = "integration")] + { + return true; + } + } + } + + false + } + + pub async fn handle_terminal_events( + &mut self, + event: Result, + ) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, @@ -456,7 +599,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) } @@ -464,7 +614,7 @@ impl Application { }; if should_redraw && !self.editor.should_close() { - self.render(); + self.render().await; } } @@ -512,14 +662,14 @@ impl Application { // trigger textDocument/didOpen for docs that are already open for doc in docs { - let language_id = - doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); - let url = match doc.url() { Some(url) => url, None => continue, // skip documents with no path }; + let language_id = + doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); + tokio::spawn(language_server.text_document_did_open( url, doc.version(), @@ -605,13 +755,29 @@ impl Application { None => None, }; + let tags = if let Some(ref tags) = diagnostic.tags { + let new_tags = tags.iter().filter_map(|tag| { + match *tag { + lsp::DiagnosticTag::DEPRECATED => Some(DiagnosticTag::Deprecated), + lsp::DiagnosticTag::UNNECESSARY => Some(DiagnosticTag::Unnecessary), + _ => None + } + }).collect(); + + new_tags + } else { + Vec::new() + }; + Some(Diagnostic { range: Range { start, end }, line: diagnostic.range.start.line as usize, message: diagnostic.message.clone(), severity, code, - // source + tags, + source: diagnostic.source.clone(), + data: diagnostic.data.clone(), }) }) .collect(); @@ -724,6 +890,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 { @@ -824,9 +1016,18 @@ 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, terminal::EnterAlternateScreen, EnableBracketedPaste)?; + execute!( + stdout, + terminal::EnterAlternateScreen, + EnableBracketedPaste, + EnableFocusChange + )?; execute!(stdout, terminal::Clear(terminal::ClearType::All))?; if self.config.load().editor.mouse { execute!(stdout, EnableMouseCapture)?; @@ -851,19 +1052,52 @@ impl Application { })); self.event_loop(input_stream).await; - self.close().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 { + self.editor.exit_code = 1; + eprintln!("Error: {}", err); + } + Ok(self.editor.exit_code) } - pub async fn close(&mut self) -> anyhow::Result<()> { - self.jobs.finish().await?; + pub async fn close(&mut self) -> Vec { + // [NOTE] we intentionally do not return early for errors because we + // want to try to run as much cleanup as we can, regardless of + // errors along the way + let mut errs = Vec::new(); + + if let Err(err) = self + .jobs + .finish(&mut self.editor, Some(&mut self.compositor)) + .await + { + log::error!("Error executing job: {}", err); + errs.push(err); + }; + + if let Err(err) = self.editor.flush_writes().await { + log::error!("Error writing: {}", err); + errs.push(err); + } if self.editor.close_language_servers(None).await.is_err() { log::error!("Timed out waiting for language servers to shutdown"); - }; + errs.push(anyhow::format_err!( + "Timed out waiting for language servers to shutdown" + )); + } - Ok(()) + errs } } diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs index d16d7dfd..dd787f1f 100644 --- a/helix-term/src/args.rs +++ b/helix-term/src/args.rs @@ -14,6 +14,7 @@ pub struct Args { pub build_grammars: bool, pub split: Option, pub verbosity: u64, + pub log_file: Option, pub config_file: Option, pub files: Vec<(PathBuf, Position)>, } @@ -31,8 +32,14 @@ impl Args { "--version" => args.display_version = true, "--help" => args.display_help = true, "--tutor" => args.load_tutor = true, - "--vsplit" => args.split = Some(Layout::Vertical), - "--hsplit" => args.split = Some(Layout::Horizontal), + "--vsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Vertical), + }, + "--hsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Horizontal), + }, "--health" => { args.health = true; args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); @@ -48,6 +55,10 @@ impl Args { Some(path) => args.config_file = Some(path.into()), None => anyhow::bail!("--config must specify a path to read"), }, + "--log" => match argv.next().as_deref() { + Some(path) => args.log_file = Some(path.into()), + None => anyhow::bail!("--log must specify a path to write"), + }, arg if arg.starts_with("--") => { anyhow::bail!("unexpected double dash argument: {}", arg) } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index e869446e..437e11b5 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3,12 +3,13 @@ pub(crate) mod lsp; pub(crate) mod typed; pub use dap::*; +use helix_vcs::Hunk; pub use lsp::*; use tui::text::Spans; pub use typed::*; use helix_core::{ - comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, + comment, coords_at_pos, encoding, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, increment::date_time::DateTimeIncrementor, increment::{number::NumberIncrementor, Increment}, @@ -27,6 +28,7 @@ use helix_core::{ SmallVec, Tendril, Transaction, }; use helix_view::{ + apply_transaction, clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::{Action, Motion}, @@ -46,12 +48,13 @@ 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, Job, Jobs}; -use futures_util::{FutureExt, StreamExt}; +use crate::job::{self, Jobs}; +use futures_util::StreamExt; use std::{collections::HashMap, fmt, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; @@ -106,10 +109,11 @@ impl<'a> Context<'a> { let callback = Box::pin(async move { let json = call.await?; let response = serde_json::from_value(json)?; - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + 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); @@ -207,17 +211,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", @@ -239,6 +244,7 @@ impl MappableCommand { select_regex, "Select all regex matches inside selections", split_selection, "Split selections on regex matches", split_selection_on_newline, "Split selection on newlines", + merge_consecutive_selections, "Merge consecutive selections", search, "Search for regex pattern", rsearch, "Reverse search for regex pattern", search_next, "Select next search match", @@ -246,6 +252,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", @@ -273,8 +280,8 @@ impl MappableCommand { diagnostics_picker, "Open diagnostic picker", workspace_diagnostics_picker, "Open workspace diagnostic picker", last_picker, "Open last picker", - prepend_to_line, "Insert at start of line", - append_to_line, "Append to end of line", + 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", @@ -303,12 +310,15 @@ impl MappableCommand { goto_last_diag, "Goto last diagnostic", goto_next_diag, "Goto next diagnostic", goto_prev_diag, "Goto previous diagnostic", + goto_next_change, "Goto next change", + goto_prev_change, "Goto previous change", + goto_first_change, "Goto first change", + goto_last_change, "Goto last change", goto_line_start, "Goto line start", 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", @@ -346,6 +356,7 @@ impl MappableCommand { 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", @@ -397,8 +408,8 @@ impl MappableCommand { 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_class, "Goto next type definition", + goto_prev_class, "Goto previous type definition", goto_next_parameter, "Goto next parameter", goto_prev_parameter, "Goto previous parameter", goto_next_comment, "Goto next comment", @@ -775,11 +786,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(); @@ -858,7 +865,7 @@ fn align_selections(cx: &mut Context) { changes.sort_unstable_by_key(|(from, _, _)| *from); let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn goto_window(cx: &mut Context, align: Align) { @@ -866,7 +873,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. @@ -885,8 +892,12 @@ fn goto_window(cx: &mut Context, align: Align) { .min(last_line.saturating_sub(scrolloff)); let pos = doc.text().line_to_char(line); - - doc.set_selection(view.id, Selection::point(pos)); + 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) { @@ -1015,6 +1026,7 @@ 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(); @@ -1024,15 +1036,25 @@ fn goto_file_impl(cx: &mut Context, action: Action) { .map(|r| text.slice(r.from()..r.to()).to_string()) .collect(); let primary = selections.primary(); - if selections.len() == 1 && primary.to() - primary.from() == 1 { - let current_word = movement::move_next_long_word_start( - text.slice(..), - movement::move_prev_long_word_start(text.slice(..), primary, 1), - 1, + // 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( - text.slice(current_word.from()..current_word.to()) + current_word + .fragment(text_slice) + .trim_matches(surrounding_chars) .to_string(), ); } @@ -1074,6 +1096,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) } @@ -1111,6 +1137,10 @@ where doc!(cx.editor).line_ending.as_str().chars().next().unwrap() } + KeyEvent { + code: KeyCode::Tab, .. + } => '\t', + KeyEvent { code: KeyCode::Char(ch), .. @@ -1257,6 +1287,9 @@ fn replace(cx: &mut Context) { code: KeyCode::Enter, .. } => Some(doc.line_ending.as_str()), + KeyEvent { + code: KeyCode::Tab, .. + } => Some("\t"), _ => None, }; @@ -1284,7 +1317,8 @@ fn replace(cx: &mut Context) { } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); } }) } @@ -1301,7 +1335,7 @@ where (range.from(), range.to(), Some(text)) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn switch_case(cx: &mut Context) { @@ -1341,7 +1375,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let range = doc.selection(view.id).primary(); let text = doc.text().slice(..); - let cursor = coords_at_pos(text, range.cursor(text)); + 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); @@ -1352,9 +1386,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, @@ -1373,7 +1407,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { // If cursor needs moving, replace primary selection if line != cursor.row { - let head = pos_at_coords(text, Position::new(line, cursor.col), true); // this func will properly truncate to line end + 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 @@ -1392,25 +1426,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); } @@ -1511,7 +1545,8 @@ fn select_regex(cx: &mut Context) { "select:".into(), Some(reg), ui::completers::none, - move |view, doc, regex, event| { + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -1532,7 +1567,8 @@ fn split_selection(cx: &mut Context) { "split:".into(), Some(reg), ui::completers::none, - move |view, doc, regex, event| { + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -1554,17 +1590,24 @@ fn split_selection_on_newline(cx: &mut Context) { doc.set_selection(view.id, selection); } +fn merge_consecutive_selections(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id).clone().merge_consecutive_ranges(); + doc.set_selection(view.id, selection); +} + #[allow(clippy::too_many_arguments)] fn search_impl( - doc: &mut Document, - view: &mut View, + 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); @@ -1594,17 +1637,29 @@ fn search_impl( Direction::Backward => regex.find_iter(&contents[..start]).last(), }; - if wrap_around && mat.is_none() { - mat = match direction { - Direction::Forward => regex.find(contents), - Direction::Backward => { - offset = start; - 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"); } } - // TODO: message on wraparound } + 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); @@ -1616,11 +1671,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), @@ -1628,12 +1679,7 @@ fn search_impl( }; 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) - } + view.ensure_cursor_in_view_center(doc, scrolloff); }; } @@ -1680,19 +1726,19 @@ fn searcher(cx: &mut Context, direction: Direction) { .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |view, doc, regex, event| { + move |editor, regex, event| { if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } search_impl( - doc, - view, + editor, &contents, ®ex, Movement::Move, direction, scrolloff, wrap_around, + false, ); }, ); @@ -1702,7 +1748,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir let count = cx.count(); let config = cx.editor.config(); let scrolloff = config.scrolloff; - let (view, doc) = current!(cx.editor); + 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(); @@ -1720,14 +1766,14 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir { for _ in 0..count { search_impl( - doc, - view, + cx.editor, &contents, ®ex, movement, direction, scrolloff, wrap_around, + true, ); } } else { @@ -1760,11 +1806,42 @@ fn search_selection(cx: &mut Context) { .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.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); } @@ -1823,7 +1900,7 @@ fn global_search(cx: &mut Context) { .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |_view, _doc, regex, event| { + move |_editor, regex, event| { if event != PromptEvent::Validate { return; } @@ -1842,10 +1919,15 @@ fn global_search(cx: &mut Context) { .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(); @@ -1897,8 +1979,8 @@ fn global_search(cx: &mut Context) { let show_picker = async move { let all_matches: Vec = UnboundedReceiverStream::new(all_matches_rx).collect().await; - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + 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; @@ -1930,11 +2012,12 @@ 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))); - }); + }, + )); Ok(call) }; cx.jobs.callback(show_picker); @@ -2016,11 +2099,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()) }), ); } @@ -2057,11 +2136,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()) }), ); } @@ -2074,23 +2149,21 @@ 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 let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); match op { Operation::Delete => { @@ -2104,14 +2177,11 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { } #[inline] -fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Selection) { - let view_id = view.id; - - // then delete +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) }); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view); } fn delete_selection(cx: &mut Context) { @@ -2159,10 +2229,7 @@ fn ensure_selections_forward(cx: &mut Context) { let selection = doc .selection(view.id) .clone() - .transform(|r| match r.direction() { - Direction::Forward => r, - Direction::Backward => r.flip(), - }); + .transform(|r| r.with_direction(Direction::Forward)); doc.set_selection(view.id, selection); } @@ -2205,12 +2272,12 @@ fn append_mode(cx: &mut Context) { .iter() .last() .expect("selection should always have at least one range"); - if !last_range.is_empty() && last_range.head == end { + 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(), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } let selection = doc.selection(view.id).clone().transform(|range| { @@ -2223,8 +2290,9 @@ fn append_mode(cx: &mut Context) { } fn file_picker(cx: &mut Context) { - // We don't specify language markers, root will be the root of the current git repo - let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./")); + // 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))); } @@ -2285,8 +2353,8 @@ fn buffer_picker(cx: &mut Context) { let picker = FilePicker::new( cx.editor .documents - .iter() - .map(|(_, doc)| new_meta(doc)) + .values() + .map(|doc| new_meta(doc)) .collect(), (), |cx, meta, action| { @@ -2299,7 +2367,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))); @@ -2366,7 +2434,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())) }) @@ -2374,13 +2441,15 @@ fn jumplist_picker(cx: &mut Context) { (), |cx, meta, action| { cx.editor.switch(meta.id, action); + let config = cx.editor.config(); let (view, doc) = current!(cx.editor); doc.set_selection(view.id, meta.selection.clone()); + view.ensure_cursor_in_view_center(doc, config.scrolloff); }, |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))); @@ -2390,29 +2459,26 @@ impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; fn label(&self, keymap: &Self::Data) -> Spans { - // formats key bindings, multiple bindings are comma separated, - // individual key presses are joined with `+` let fmt_binding = |bindings: &Vec>| -> String { - bindings - .iter() - .map(|bind| { - bind.iter() - .map(|key| key.to_string()) - .collect::>() - .join("+") - }) - .collect::>() - .join(", ") + 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)).into(), - None => doc.as_str().into(), + 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)).into(), - None => (*doc).into(), + Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [{}]", doc, name).into(), }, } } @@ -2452,23 +2518,23 @@ pub fn command_palette(cx: &mut Context) { fn last_picker(cx: &mut Context) { // TODO: last picker does not seem to work well with buffer_picker - cx.callback = Some(Box::new(|compositor: &mut Compositor, _| { + 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") } - // XXX: figure out how to show error when no last picker lifetime - // cx.editor.set_error("no last picker") })); } // I inserts at the first nonwhitespace character of each line with a selection -fn prepend_to_line(cx: &mut Context) { +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 append_to_line(cx: &mut Context) { +fn insert_at_line_end(cx: &mut Context) { enter_insert_mode(cx); let (view, doc) = current!(cx.editor); @@ -2481,13 +2547,6 @@ fn append_to_line(cx: &mut Context) { doc.set_selection(view.id, selection); } -/// Sometimes when applying formatting changes we want to mark the buffer as unmodified, for -/// example because we just applied the same changes while saving. -enum Modified { - SetUnmodified, - LeaveModified, -} - // 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. // @@ -2496,29 +2555,44 @@ enum Modified { async fn make_format_callback( doc_id: DocumentId, doc_version: i32, - modified: Modified, + view_id: ViewId, format: impl Future> + Send + 'static, + write: Option<(Option, bool)>, ) -> anyhow::Result { - let format = format.await?; - let call: job::Callback = Box::new(move |editor, _compositor| { - let view_id = view!(editor).id; - if let Some(doc) = editor.document_mut(doc_id) { + 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 { - doc.apply(&format, view_id); - doc.append_changes_to_history(view_id); + apply_transaction(&format, doc, view); + doc.append_changes_to_history(view); doc.detect_indent_and_line_ending(); - if let Modified::SetUnmodified = modified { - doc.reset_modified(); - } + 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)] +#[derive(PartialEq, Eq)] pub enum Open { Below, Above, @@ -2591,7 +2665,7 @@ fn open(cx: &mut Context, open: Open) { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } // o inserts a new line after each line with a selection @@ -2605,62 +2679,7 @@ fn open_above(cx: &mut Context) { } 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.id); - - // 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_id: ViewId) { - 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) - }); - doc.apply(&transaction, view_id); - } + cx.editor.enter_normal_mode(); } // Store a jump on the jumplist. @@ -2676,15 +2695,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() @@ -2697,14 +2716,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() @@ -2786,26 +2805,27 @@ fn goto_pos(editor: &mut Editor, pos: usize) { } fn goto_first_diag(cx: &mut Context) { - let doc = doc!(cx.editor); - let pos = match doc.diagnostics().first() { - Some(diag) => diag.range.start, + let (view, doc) = current!(cx.editor); + let selection = match doc.diagnostics().first() { + Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; - goto_pos(cx.editor, pos); + doc.set_selection(view.id, selection); + align_view(doc, view, Align::Center); } fn goto_last_diag(cx: &mut Context) { - let doc = doc!(cx.editor); - let pos = match doc.diagnostics().last() { - Some(diag) => diag.range.start, + let (view, doc) = current!(cx.editor); + let selection = match doc.diagnostics().last() { + Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; - goto_pos(cx.editor, pos); + doc.set_selection(view.id, selection); + align_view(doc, view, Align::Center); } fn goto_next_diag(cx: &mut Context) { - let editor = &mut cx.editor; - let (view, doc) = current!(editor); + let (view, doc) = current!(cx.editor); let cursor_pos = doc .selection(view.id) @@ -2818,17 +2838,16 @@ fn goto_next_diag(cx: &mut Context) { .find(|diag| diag.range.start > cursor_pos) .or_else(|| doc.diagnostics().first()); - let pos = match diag { - Some(diag) => diag.range.start, + let selection = match diag { + Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; - - goto_pos(editor, pos); + doc.set_selection(view.id, selection); + align_view(doc, view, Align::Center); } fn goto_prev_diag(cx: &mut Context) { - let editor = &mut cx.editor; - let (view, doc) = current!(editor); + let (view, doc) = current!(cx.editor); let cursor_pos = doc .selection(view.id) @@ -2842,12 +2861,108 @@ fn goto_prev_diag(cx: &mut Context) { .find(|diag| diag.range.start < cursor_pos) .or_else(|| doc.diagnostics().last()); - let pos = match diag { - Some(diag) => diag.range.start, + let selection = match diag { + // NOTE: the selection is reversed because we're jumping to the + // previous diagnostic. + Some(diag) => Selection::single(diag.range.end, diag.range.start), None => return, }; + doc.set_selection(view.id, selection); + align_view(doc, view, Align::Center); +} + +fn goto_first_change(cx: &mut Context) { + goto_first_change_impl(cx, false); +} + +fn goto_last_change(cx: &mut Context) { + goto_first_change_impl(cx, true); +} + +fn goto_first_change_impl(cx: &mut Context, reverse: bool) { + let editor = &mut cx.editor; + let (_, doc) = current!(editor); + if let Some(handle) = doc.diff_handle() { + let hunk = { + let hunks = handle.hunks(); + let idx = if reverse { + hunks.len().saturating_sub(1) + } else { + 0 + }; + hunks.nth_hunk(idx) + }; + if hunk != Hunk::NONE { + let pos = doc.text().line_to_char(hunk.after.start as usize); + goto_pos(editor, pos) + } + } +} + +fn goto_next_change(cx: &mut Context) { + goto_next_change_impl(cx, Direction::Forward) +} - goto_pos(editor, pos); +fn goto_prev_change(cx: &mut Context) { + goto_next_change_impl(cx, Direction::Backward) +} + +fn goto_next_change_impl(cx: &mut Context, direction: Direction) { + let count = cx.count() as u32 - 1; + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); + let doc_text = doc.text().slice(..); + let diff_handle = if let Some(diff_handle) = doc.diff_handle() { + diff_handle + } else { + editor.set_status("Diff is not available in current buffer"); + return; + }; + + let selection = doc.selection(view.id).clone().transform(|range| { + let cursor_line = range.cursor_line(doc_text) as u32; + + let hunks = diff_handle.hunks(); + let hunk_idx = match direction { + Direction::Forward => hunks + .next_hunk(cursor_line) + .map(|idx| (idx + count).min(hunks.len() - 1)), + Direction::Backward => hunks + .prev_hunk(cursor_line) + .map(|idx| idx.saturating_sub(count)), + }; + // TODO refactor with let..else once MSRV reaches 1.65 + let hunk_idx = if let Some(hunk_idx) = hunk_idx { + hunk_idx + } else { + return range; + }; + let hunk = hunks.nth_hunk(hunk_idx); + + let hunk_start = doc_text.line_to_char(hunk.after.start as usize); + let hunk_end = if hunk.after.is_empty() { + hunk_start + 1 + } else { + doc_text.line_to_char(hunk.after.end as usize) + }; + let new_range = Range::new(hunk_start, hunk_end); + 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) + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); } pub mod insert { @@ -2857,7 +2972,7 @@ pub mod insert { /// Exclude the cursor in range. fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { - if range.to() == cursor.to() { + if range.to() == cursor.to() && text.len_chars() != cursor.to() { Range::new( range.from(), graphemes::prev_grapheme_boundary(text, cursor.to()), @@ -2888,6 +3003,11 @@ pub mod insert { } fn language_server_completion(cx: &mut Context, ch: char) { + let config = cx.editor.config(); + if !config.auto_completion { + return; + } + use helix_lsp::lsp; // if ch matches completion char, trigger completion let doc = doc_mut!(cx.editor); @@ -2973,7 +3093,7 @@ pub mod insert { let (view, doc) = current!(cx.editor); if let Some(t) = transaction { - doc.apply(&t, view.id); + apply_transaction(&t, doc, view); } // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) @@ -2989,13 +3109,13 @@ pub mod insert { // 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_unit()); + 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, ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub fn insert_newline(cx: &mut Context) { @@ -3020,40 +3140,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 { @@ -3074,22 +3213,22 @@ 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())); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + 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_unit(); + let indent_unit = doc.indent_style.as_str(); let tab_size = doc.tab_width(); let auto_pairs = doc.auto_pairs(cx.editor); @@ -3154,6 +3293,7 @@ pub mod insert { (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 { @@ -3176,7 +3316,7 @@ pub mod insert { } }); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3194,7 +3334,7 @@ pub mod insert { None, ) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3205,8 +3345,8 @@ pub mod insert { let text = doc.text().slice(..); let selection = doc.selection(view.id).clone().transform(|range| { - let cursor = Range::point(range.cursor(text)); - let next = movement::move_prev_word_start(text, cursor, count); + 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); @@ -3219,10 +3359,11 @@ pub mod insert { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); - let selection = doc - .selection(view.id) - .clone() - .transform(|range| movement::move_next_word_start(text, range, count)); + 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); @@ -3235,7 +3376,7 @@ fn undo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.undo(view.id) { + if !doc.undo(view) { cx.editor.set_status("Already at oldest change"); break; } @@ -3246,7 +3387,7 @@ fn redo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.redo(view.id) { + if !doc.redo(view) { cx.editor.set_status("Already at newest change"); break; } @@ -3258,7 +3399,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.id, UndoKind::Steps(1)) { + if !doc.earlier(view, UndoKind::Steps(1)) { cx.editor.set_status("Already at oldest change"); break; } @@ -3270,7 +3411,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.id, UndoKind::Steps(1)) { + if !doc.later(view, UndoKind::Steps(1)) { cx.editor.set_status("Already at newest change"); break; } @@ -3279,7 +3420,7 @@ fn later(cx: &mut Context) { fn commit_undo_checkpoint(cx: &mut Context) { let (view, doc) = current!(cx.editor); - doc.append_changes_to_history(view.id); + doc.append_changes_to_history(view); } // Yank / Paste @@ -3322,9 +3463,15 @@ fn yank_joined_to_clipboard_impl( .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 system clipboard", + "joined and yanked {} selection(s) to {}", values.len(), + clipboard_text, ); let joined = values.join(separator); @@ -3353,6 +3500,11 @@ fn yank_main_selection_to_clipboard_impl( 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 @@ -3362,7 +3514,7 @@ fn yank_main_selection_to_clipboard_impl( bail!("Couldn't set system clipboard content: {}", e); } - editor.set_status("yanked main selection to system clipboard"); + editor.set_status(message_text); Ok(()) } @@ -3388,8 +3540,20 @@ enum Paste { Cursor, } -fn paste_impl(values: &[String], doc: &mut Document, view: &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; + } + let repeat = std::iter::repeat( + // `values` is asserted to have at least one entry above. values .last() .map(|value| Tendril::from(value.repeat(count))) @@ -3402,7 +3566,6 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, .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() @@ -3413,7 +3576,10 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, let text = doc.text(); let selection = doc.selection(view.id); - let transaction = Transaction::change_by_selection(text, selection, |range| { + 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())), @@ -3429,9 +3595,27 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, // paste at cursor (Paste::Cursor, _) => range.cursor(text.slice(..)), }; - (pos, pos, values.next()) + + 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) }); - doc.apply(&transaction, view.id); + + 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) { @@ -3441,7 +3625,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( @@ -3453,7 +3637,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")), @@ -3523,19 +3707,20 @@ fn replace_with_yanked(cx: &mut Context) { } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); } } } fn replace_selections_with_clipboard_impl( - editor: &mut Editor, + cx: &mut Context, clipboard_type: ClipboardType, - count: usize, ) -> anyhow::Result<()> { - let (view, doc) = current!(editor); + let count = cx.count(); + let (view, doc) = current!(cx.editor); - match editor.clipboard_provider.get_contents(clipboard_type) { + 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| { @@ -3546,20 +3731,22 @@ fn replace_selections_with_clipboard_impl( ) }); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); - Ok(()) + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); } - Err(e) => Err(e.context("Couldn't get system clipboard contents")), + 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.editor, ClipboardType::Clipboard, cx.count()); + 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.editor, ClipboardType::Selection, cx.count()); + let _ = replace_selections_with_clipboard_impl(cx, ClipboardType::Selection); } fn paste(cx: &mut Context, pos: Paste) { @@ -3569,7 +3756,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); } } @@ -3603,7 +3790,7 @@ fn indent(cx: &mut Context) { let lines = get_lines(doc, view.id); // Indent by one level - let indent = Tendril::from(doc.indent_unit().repeat(count)); + let indent = Tendril::from(doc.indent_style.as_str().repeat(count)); let transaction = Transaction::change( doc.text(), @@ -3616,7 +3803,7 @@ fn indent(cx: &mut Context) { Some((pos, pos, Some(indent.clone()))) }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn unindent(cx: &mut Context) { @@ -3655,7 +3842,7 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn format_selections(cx: &mut Context) { @@ -3664,7 +3851,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, @@ -3677,36 +3864,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 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 = block_on(language_server.text_document_range_formatting( - // doc.identifier(), - // range, - // lsp::FormattingOptions::default(), - // )) - // .unwrap_or_default(); + 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(), - // ); + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + edits, + language_server.offset_encoding(), + ); - // doc.apply(&transaction, view.id); - } + apply_transaction(&transaction, doc, view); } -fn join_selections(cx: &mut Context) { +fn join_selections_impl(cx: &mut Context, select_space: bool) { use movement::skip_while; let (view, doc) = current!(cx.editor); let text = doc.text(); @@ -3741,11 +3935,23 @@ fn join_selections(cx: &mut Context) { // TODO: joining multiple empty lines should be replaced by a single space. // need to merge change ranges that touch - let transaction = Transaction::change(doc.text(), changes.into_iter()); - // TODO: select inserted spaces - // .with_selection(selection); + // 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()) + }; - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { @@ -3756,7 +3962,8 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { if remove { "remove:" } else { "keep:" }.into(), Some(reg), ui::completers::none, - move |view, doc, regex, event| { + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -3771,6 +3978,14 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { ) } +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) } @@ -3818,7 +4033,10 @@ 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 = match language_server.completion(doc.identifier(), pos, None) { + Some(future) => future, + None => return, + }; let trigger_offset = cursor; @@ -3830,7 +4048,6 @@ 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(); cx.callback( future, @@ -3840,7 +4057,7 @@ pub fn completion(cx: &mut Context) { 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 { @@ -3850,18 +4067,6 @@ pub fn completion(cx: &mut Context) { None => Vec::new(), }; - if !prefix.is_empty() { - items = items - .into_iter() - .filter(|item| { - item.filter_text - .as_ref() - .unwrap_or(&item.label) - .starts_with(&prefix) - }) - .collect(); - } - if items.is_empty() { // editor.set_error("No completion available"); return; @@ -3889,7 +4094,7 @@ fn toggle_comments(cx: &mut Context) { .map(|tc| tc.as_ref()); let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); exit_select_mode(cx); } @@ -3945,7 +4150,7 @@ fn rotate_selection_contents(cx: &mut Context, direction: Direction) { .map(|(range, fragment)| (range.from(), range.to(), Some(fragment))), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn rotate_selection_contents_forward(cx: &mut Context) { @@ -4055,6 +4260,7 @@ fn match_brackets(cx: &mut Context) { fn jump_forward(cx: &mut Context) { let count = cx.count(); + let config = cx.editor.config(); let view = view_mut!(cx.editor); let doc_id = view.doc; @@ -4068,12 +4274,13 @@ fn jump_forward(cx: &mut Context) { } doc.set_selection(view.id, selection); - align_view(doc, view, Align::Center); + view.ensure_cursor_in_view_center(doc, config.scrolloff); }; } fn jump_backward(cx: &mut Context) { let count = cx.count(); + let config = cx.editor.config(); let (view, doc) = current!(cx.editor); let doc_id = doc.id(); @@ -4087,7 +4294,7 @@ fn jump_backward(cx: &mut Context) { } doc.set_selection(view.id, selection); - align_view(doc, view, Align::Center); + view.ensure_cursor_in_view_center(doc, config.scrolloff); }; } @@ -4240,7 +4447,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) { @@ -4260,7 +4467,7 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct let root = syntax.tree().root_node(); let selection = doc.selection(view.id).clone().transform(|range| { - movement::goto_treesitter_object( + let new_range = movement::goto_treesitter_object( text, range, object, @@ -4268,7 +4475,19 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct 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); @@ -4333,7 +4552,6 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { cx.on_next_key(move |cx, event| { cx.editor.autoinfo = None; - cx.editor.pseudo_pending = None; if let Some(ch) = event.char() { let textobject = move |editor: &mut Editor| { let (view, doc) = current!(editor); @@ -4355,19 +4573,41 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { ) }; + if ch == 'g' && doc.diff_handle().is_none() { + editor.set_status("Diff is not available in current buffer"); + return; + } + + let textobject_change = |range: Range| -> Range { + let diff_handle = doc.diff_handle().unwrap(); + let hunks = diff_handle.hunks(); + let line = range.cursor_line(text); + let hunk_idx = if let Some(hunk_idx) = hunks.hunk_at(line as u32, false) { + hunk_idx + } else { + return range; + }; + let hunk = hunks.nth_hunk(hunk_idx).after; + + let start = text.line_to_char(hunk.start as usize); + let end = text.line_to_char(hunk.end as usize); + Range::new(start, end).with_direction(range.direction()) + }; + 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), + 't' => textobject_treesitter("class", range), 'f' => textobject_treesitter("function", range), 'a' => textobject_treesitter("parameter", range), - 'o' => textobject_treesitter("comment", range), - 't' => textobject_treesitter("test", range), + 'c' => 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, ), + 'g' => textobject_change(range), // 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) @@ -4382,33 +4622,25 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { } }); - if let Some((title, abbrev)) = match objtype { - textobject::TextObject::Inside => Some(("Match inside", "mi")), - textobject::TextObject::Around => Some(("Match around", "ma")), + 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 - .into_iter() - .map(|(col1, col2)| (col1.to_string(), col2.to_string())) - .collect(), - )); - cx.editor.pseudo_pending = Some(abbrev.to_string()); }; + let help_text = [ + ("w", "Word"), + ("W", "WORD"), + ("p", "Paragraph"), + ("t", "Type definition (tree-sitter)"), + ("f", "Function (tree-sitter)"), + ("a", "Argument/parameter (tree-sitter)"), + ("c", "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) { @@ -4420,8 +4652,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); @@ -4429,10 +4666,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()); - doc.apply(&transaction, view.id); + 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); }) } @@ -4471,7 +4719,8 @@ fn surround_replace(cx: &mut Context) { (pos, pos + 1, Some(t)) }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); }); }) } @@ -4498,7 +4747,8 @@ fn surround_delete(cx: &mut Context) { let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); + exit_select_mode(cx); }) } @@ -4550,7 +4800,7 @@ fn shell_keep_pipe(cx: &mut Context) { for (i, range) in selection.ranges().iter().enumerate() { let fragment = range.slice(text); - let (_output, success) = match shell_impl(shell, input, Some(fragment)) { + let (_output, success) = match shell_impl(shell, input, Some(fragment.into())) { Ok(result) => result, Err(err) => { cx.editor.set_error(err.to_string()); @@ -4578,13 +4828,17 @@ fn shell_keep_pipe(cx: &mut Context) { ); } -fn shell_impl( +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, + input: Option, ) -> anyhow::Result<(Tendril, bool)> { - use std::io::Write; - use std::process::{Command, Stdio}; + use std::process::Stdio; + use tokio::process::Command; ensure!(!shell.is_empty(), "No shell set"); let mut process = Command::new(&shell[0]); @@ -4596,6 +4850,8 @@ fn shell_impl( if input.is_some() || cfg!(windows) { process.stdin(Stdio::piped()); + } else { + process.stdin(Stdio::null()); } let mut process = match process.spawn() { @@ -4605,13 +4861,22 @@ fn shell_impl( return Err(e.into()); } }; - if let Some(input) = input { - let mut stdin = process.stdin.take().unwrap(); - for chunk in input.chunks() { - stdin.write_all(chunk.as_bytes())?; - } - } - let output = process.wait_with_output()?; + 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() { @@ -4645,15 +4910,27 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { 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 fragment = range.slice(text); - let (output, success) = match shell_impl(shell, cmd, pipe.then(|| fragment)) { - 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; + } } }; @@ -4662,19 +4939,31 @@ 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), }; + + // 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()); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); + 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); } // after replace cursor may be out of bounds, do this to @@ -4736,17 +5025,21 @@ fn add_newline_impl(cx: &mut Context, open: Open) { }); let transaction = Transaction::change(text, changes); - doc.apply(&transaction, view.id); + 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 @@ -4770,7 +5063,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( @@ -4782,6 +5075,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(..); @@ -4801,6 +5105,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(); @@ -4817,19 +5123,23 @@ 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()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } @@ -4848,7 +5158,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/dap.rs b/helix-term/src/commands/dap.rs index 12a3fbc7..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)); @@ -118,11 +118,14 @@ fn dap_callback( let callback = Box::pin(async move { let json = call.await?; let response = serde_json::from_value(json)?; - let call: Callback = Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { - callback(editor, compositor, response) - }); + let call: Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + callback(editor, compositor, response) + }, + )); Ok(call) }); + jobs.callback(callback); } @@ -274,10 +277,11 @@ pub fn dap_launch(cx: &mut Context) { let completions = template.completion.clone(); let name = template.name.clone(); let callback = Box::pin(async move { - let call: Callback = Box::new(move |_editor, compositor| { - let prompt = debug_parameter_prompt(completions, name, Vec::new()); - compositor.push(Box::new(prompt)); - }); + let call: Callback = + Callback::EditorCompositor(Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, name, Vec::new()); + compositor.push(Box::new(prompt)); + })); Ok(call) }); cx.jobs.callback(callback); @@ -332,10 +336,11 @@ fn debug_parameter_prompt( let config_name = config_name.clone(); let params = params.clone(); let callback = Box::pin(async move { - let call: Callback = Box::new(move |_editor, compositor| { - let prompt = debug_parameter_prompt(completions, config_name, params); - compositor.push(Box::new(prompt)); - }); + let call: Callback = + Callback::EditorCompositor(Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, config_name, params); + compositor.push(Box::new(prompt)); + })); Ok(call) }); cx.jobs.callback(callback); @@ -582,7 +587,7 @@ pub fn dap_edit_condition(cx: &mut Context) { None => return, }; let callback = Box::pin(async move { - let call: Callback = Box::new(move |editor, compositor| { + let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { let mut prompt = Prompt::new( "condition:".into(), None, @@ -610,7 +615,7 @@ pub fn dap_edit_condition(cx: &mut Context) { prompt.insert_str(&condition, editor) } compositor.push(Box::new(prompt)); - }); + })); Ok(call) }); cx.jobs.callback(callback); @@ -624,7 +629,7 @@ pub fn dap_edit_log(cx: &mut Context) { None => return, }; let callback = Box::pin(async move { - let call: Callback = Box::new(move |editor, compositor| { + let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { let mut prompt = Prompt::new( "log-message:".into(), None, @@ -651,7 +656,7 @@ pub fn dap_edit_log(cx: &mut Context) { prompt.insert_str(&log_message, editor); } compositor.push(Box::new(prompt)); - }); + })); Ok(call) }); cx.jobs.callback(callback); @@ -701,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 1113b44e..86b0c5fa 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,6 +1,7 @@ +use futures_util::FutureExt; use helix_lsp::{ block_on, - lsp::{self, DiagnosticSeverity, NumberOrString}, + lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString}, util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, OffsetEncoding, }; @@ -9,16 +10,19 @@ use tui::text::{Span, Spans}; use super::{align_view, push_jump, Align, Context, Editor, Open}; use helix_core::{path, Selection}; -use helix_view::{editor::Action, theme::Style}; +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, + self, lsp::SignatureHelp, overlay::overlayed, DynamicPicker, FileLocation, FilePicker, + Popup, PromptEvent, }, }; -use std::{borrow::Cow, collections::BTreeMap, path::PathBuf, sync::Arc}; +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 @@ -43,23 +47,32 @@ impl ui::menu::Item for lsp::Location { type Data = PathBuf; fn label(&self, cwdir: &Self::Data) -> Spans { - let file: Cow<'_, str> = (self.uri.scheme() == "file") - .then(|| { - self.uri - .to_file_path() - .map(|path| { - // strip root prefix - path.strip_prefix(&cwdir) - .map(|path| path.to_path_buf()) - .unwrap_or(path) - }) - .map(|path| Cow::from(path.to_string_lossy().into_owned())) - .ok() - }) - .flatten() - .unwrap_or_else(|| self.uri.as_str().into()); - let line = self.range.start.line; - format!("{}:{}", file, line).into() + // 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() } } @@ -73,10 +86,8 @@ impl ui::menu::Item for lsp::SymbolInformation { } else { match self.location.uri.to_file_path() { Ok(path) => { - let relative_path = helix_core::path::get_relative_path(path.as_path()) - .to_string_lossy() - .into_owned(); - format!("{} ({})", &self.name, relative_path).into() + 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(), } @@ -115,24 +126,21 @@ impl ui::menu::Item for PickerDiagnostic { // remove background as it is distracting in the picker list style.bg = None; - let code = self + let code: Cow<'_, str> = self .diag .code .as_ref() .map(|c| match c { - NumberOrString::Number(n) => n.to_string(), - NumberOrString::String(s) => s.to_string(), + NumberOrString::Number(n) => n.to_string().into(), + NumberOrString::String(s) => s.as_str().into(), }) - .map(|code| format!(" ({})", code)) .unwrap_or_default(); let path = match format { DiagnosticsFormat::HideSourcePath => String::new(), DiagnosticsFormat::ShowSourcePath => { - let path = path::get_truncated_path(self.url.path()) - .to_string_lossy() - .into_owned(); - format!("{}: ", path) + let path = path::get_truncated_path(self.url.path()); + format!("{}: ", path.to_string_lossy()) } }; @@ -150,7 +158,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) @@ -211,7 +219,6 @@ fn sym_picker( Ok(path) => path, Err(_) => { let err = format!("unable to convert URI to filepath: {}", uri); - log::error!("{}", err); cx.editor.set_error(err); return; } @@ -328,7 +335,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, @@ -360,15 +374,55 @@ 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, 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))) - } + let symbols = response.unwrap_or_default(); + let picker = sym_picker(symbols, current_url, offset_encoding); + let get_symbols = |query: String, editor: &mut Editor| { + let doc = doc!(editor); + let language_server = match doc.language_server() { + Some(s) => s, + None => { + // This should not generally happen since the picker will not + // even open in the first place if there is no server. + return async move { Err(anyhow::anyhow!("LSP not active")) }.boxed(); + } + }; + let symbol_request = match language_server.workspace_symbols(query) { + Some(future) => future, + None => { + // This should also not happen since the language server must have + // supported workspace symbols before to reach this block. + return async move { + Err(anyhow::anyhow!( + "Language server does not support workspace symbols" + )) + } + .boxed(); + } + }; + + let future = async move { + let json = symbol_request.await?; + let response: Option> = + serde_json::from_value(json)?; + + Ok(response.unwrap_or_default()) + }; + future.boxed() + }; + let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); + compositor.push(Box::new(overlayed(dyn_picker))) }, ) } @@ -421,6 +475,63 @@ impl ui::menu::Item for lsp::CodeActionOrCommand { } } +/// 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); @@ -431,7 +542,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 @@ -447,20 +558,72 @@ 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, move |editor, compositor, response: Option| { - let actions = match response { + 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. + // Many details are modeled after vscode because langauge servers are usually tested against it. + // 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, (), move |editor, code_action, event| { if event != PromptEvent::Validate { return; @@ -491,20 +654,35 @@ pub fn code_action(cx: &mut Context) { }); picker.move_down(); // pre-select the first item - let popup = Popup::new("code-action", picker); + let popup = Popup::new("code-action", picker).with_scrollbar(false); compositor.replace_or_push("code-action", popup); }, ) } + +impl ui::menu::Item for lsp::Command { + type Data = (); + fn label(&self, _data: &Self::Data) -> Spans { + self.title.as_str().into() + } +} + 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 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); @@ -527,7 +705,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { // Create directory if it does not exist if let Some(dir) = path.parent() { if !dir.is_dir() { - fs::create_dir_all(&dir)?; + fs::create_dir_all(dir)?; } } @@ -563,7 +741,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { if ignore_if_exists && to.exists() { Ok(()) } else { - fs::rename(&from, &to) + fs::rename(from, &to) } } } @@ -596,9 +774,7 @@ pub fn apply_workspace_edit( } }; - let doc = editor - .document_mut(doc_id) - .expect("Document for document_changes not found"); + let doc = doc_mut!(editor, &doc_id); // Need to determine a view for apply/append_changes_to_history let selections = doc.selections(); @@ -619,8 +795,9 @@ pub fn apply_workspace_edit( text_edits, offset_encoding, ); - doc.apply(&transaction, view_id); - doc.append_changes_to_history(view_id); + let view = view_mut!(editor, view_id); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); }; if let Some(ref changes) = workspace_edit.changes { @@ -740,7 +917,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, @@ -758,7 +942,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, @@ -776,7 +967,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, @@ -794,7 +992,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, @@ -805,7 +1010,7 @@ pub fn goto_reference(cx: &mut Context) { ); } -#[derive(PartialEq)] +#[derive(PartialEq, Eq)] pub enum SignatureHelpInvoked { Manual, Automatic, @@ -837,7 +1042,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( @@ -852,6 +1063,14 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { 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. @@ -930,7 +1149,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, @@ -1000,8 +1226,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()), } @@ -1016,7 +1250,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/typed.rs b/helix-term/src/commands/typed.rs index c22f8712..c2ca1a47 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,8 +1,13 @@ use std::ops::Deref; +use crate::job::Job; + use super::*; -use helix_view::editor::{Action, ConfigEvent}; +use helix_view::{ + apply_transaction, + editor::{Action, CloseError, ConfigEvent}, +}; use ui::completers::{self, Completer}; #[derive(Clone)] @@ -16,6 +21,8 @@ pub struct TypableCommand { } fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { + log::debug!("quitting..."); + if event != PromptEvent::Validate { return Ok(()); } @@ -27,6 +34,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> buffers_remaining_impl(cx.editor)? } + cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) @@ -43,6 +51,7 @@ fn force_quit( ensure!(args.is_empty(), ":quit! takes no arguments"); + cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) @@ -56,23 +65,62 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> ensure!(!args.is_empty(), "wrong argument count"); for arg in args { let (path, pos) = args::parse_file(arg); - let _ = cx.editor.open(&path, Action::Replace)?; - let (view, doc) = current!(cx.editor); - let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); - doc.set_selection(view.id, pos); - // does not affect opening a buffer without pos - align_view(doc, view, Align::Center); + // If the path is a directory, open a file picker on that directory and update the status + // message + if let Ok(true) = std::fs::canonicalize(&path).map(|p| p.is_dir()) { + let callback = async move { + let call: job::Callback = job::Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + let picker = ui::file_picker(path, &editor.config()); + compositor.push(Box::new(overlayed(picker))); + }, + )); + Ok(call) + }; + cx.jobs.callback(callback); + } else { + // Otherwise, just open the file + let _ = cx.editor.open(&path, Action::Replace)?; + let (view, doc) = current!(cx.editor); + let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); + doc.set_selection(view.id, pos); + // does not affect opening a buffer without pos + align_view(doc, view, Align::Center); + } } Ok(()) } fn buffer_close_by_ids_impl( - editor: &mut Editor, + cx: &mut compositor::Context, doc_ids: &[DocumentId], force: bool, ) -> anyhow::Result<()> { - for &doc_id in doc_ids { - editor.close_document(doc_id, force)?; + cx.block_try_flush_writes()?; + + let (modified_ids, modified_names): (Vec<_>, Vec<_>) = doc_ids + .iter() + .filter_map(|&doc_id| { + if let Err(CloseError::BufferModified(name)) = cx.editor.close_document(doc_id, force) { + Some((doc_id, name)) + } else { + None + } + }) + .unzip(); + + if let Some(first) = modified_ids.first() { + let current = doc!(cx.editor); + // If the current document is unmodified, and there are modified + // documents, switch focus to the first modified doc. + if !modified_ids.contains(¤t.id()) { + cx.editor.switch(*first, Action::Replace); + } + bail!( + "{} unsaved buffer(s) remaining: {:?}", + modified_names.len(), + modified_names + ); } Ok(()) @@ -125,7 +173,7 @@ fn buffer_close( } let document_ids = buffer_gather_paths_impl(cx.editor, args); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) + buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close( @@ -138,7 +186,7 @@ fn force_buffer_close( } let document_ids = buffer_gather_paths_impl(cx.editor, args); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) + buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { @@ -160,7 +208,7 @@ fn buffer_close_others( } let document_ids = buffer_gather_others_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) + buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close_others( @@ -173,7 +221,7 @@ fn force_buffer_close_others( } let document_ids = buffer_gather_others_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) + buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { @@ -190,7 +238,7 @@ fn buffer_close_all( } let document_ids = buffer_gather_all_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) + buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close_all( @@ -203,7 +251,7 @@ fn force_buffer_close_all( } let document_ids = buffer_gather_all_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) + buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_next( @@ -237,39 +285,30 @@ fn write_impl( path: Option<&Cow>, force: bool, ) -> anyhow::Result<()> { - let auto_format = cx.editor.config().auto_format; + let editor_auto_fmt = cx.editor.config().auto_format; let jobs = &mut cx.jobs; - let doc = doc_mut!(cx.editor); + let (view, doc) = current!(cx.editor); + let path = path.map(AsRef::as_ref); - if let Some(ref path) = path { - doc.set_path(Some(path.as_ref().as_ref())) - .context("invalid filepath")?; - } - if doc.path().is_none() { - bail!("cannot write a buffer without a filename"); - } - let fmt = if auto_format { + let fmt = if editor_auto_fmt { doc.auto_format().map(|fmt| { - let shared = fmt.shared(); let callback = make_format_callback( doc.id(), doc.version(), - Modified::SetUnmodified, - shared.clone(), + view.id, + fmt, + Some((path.map(Into::into), force)), ); - jobs.callback(callback); - shared + + jobs.add(Job::with_callback(callback).wait_before_exiting()); }) } else { None }; - let future = doc.format_and_save(fmt, force); - cx.jobs.add(Job::new(future).wait_before_exiting()); - if path.is_some() { + if fmt.is_none() { let id = doc.id(); - doc.detect_language(cx.editor.syn_loader.clone()); - let _ = cx.editor.refresh_language_server(id); + cx.editor.save(id, path, force)?; } Ok(()) @@ -322,10 +361,9 @@ fn format( return Ok(()); } - let doc = doc!(cx.editor); + let (view, doc) = current!(cx.editor); if let Some(format) = doc.format() { - let callback = - make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); + let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None); cx.jobs.callback(callback); } @@ -441,8 +479,8 @@ fn set_line_ending( } }), ); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); Ok(()) } @@ -459,7 +497,7 @@ fn earlier( let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.earlier(view.id, uk); + let success = doc.earlier(view, uk); if !success { cx.editor.set_status("Already at oldest change"); } @@ -478,7 +516,7 @@ fn later( let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.later(view.id, uk); + let success = doc.later(view, uk); if !success { cx.editor.set_status("Already at newest change"); } @@ -496,7 +534,7 @@ fn write_quit( } write_impl(cx, args.first(), false)?; - helix_lsp::block_on(cx.jobs.finish())?; + cx.block_try_flush_writes()?; quit(cx, &[], event) } @@ -510,135 +548,157 @@ fn force_write_quit( } write_impl(cx, args.first(), true)?; + cx.block_try_flush_writes()?; force_quit(cx, &[], event) } -/// Results an error if there are modified buffers remaining and sets editor error, -/// otherwise returns `Ok(())` +/// Results in an error if there are modified buffers remaining and sets editor +/// error, otherwise returns `Ok(())`. If the current document is unmodified, +/// and there are modified documents, switches focus to one of them. pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { - let modified: Vec<_> = editor + let (modified_ids, modified_names): (Vec<_>, Vec<_>) = editor .documents() .filter(|doc| doc.is_modified()) - .map(|doc| { - doc.relative_path() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) - }) - .collect(); - if !modified.is_empty() { + .map(|doc| (doc.id(), doc.display_name())) + .unzip(); + if let Some(first) = modified_ids.first() { + let current = doc!(editor); + // If the current document is unmodified, and there are modified + // documents, switch focus to the first modified doc. + if !modified_ids.contains(¤t.id()) { + editor.switch(*first, Action::Replace); + } bail!( "{} unsaved buffer(s) remaining: {:?}", - modified.len(), - modified + modified_names.len(), + modified_names ); } Ok(()) } -fn write_all_impl( +pub fn write_all_impl( cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, - quit: bool, force: bool, + write_scratch: bool, ) -> anyhow::Result<()> { - if event != PromptEvent::Validate { - return Ok(()); - } - - let mut errors = String::new(); + let mut errors: Vec<&'static str> = Vec::new(); let auto_format = cx.editor.config().auto_format; let jobs = &mut cx.jobs; + let current_view = view!(cx.editor); + // save all documents - for doc in &mut cx.editor.documents.values_mut() { - if doc.path().is_none() { - errors.push_str("cannot write a buffer without a filename\n"); - continue; - } + let saves: Vec<_> = cx + .editor + .documents + .values_mut() + .filter_map(|doc| { + if !doc.is_modified() { + return None; + } + if doc.path().is_none() { + if write_scratch { + errors.push("cannot write a buffer without a filename\n"); + } + return None; + } - if !doc.is_modified() { - continue; - } + // Look for a view to apply the formatting change to. If the document + // is in the current view, just use that. Otherwise, since we don't + // have any other metric available for better selection, just pick + // the first view arbitrarily so that we still commit the document + // state for undos. If somehow we have a document that has not been + // initialized with any view, initialize it with the current view. + let target_view = if doc.selections().contains_key(¤t_view.id) { + current_view.id + } else if let Some(view) = doc.selections().keys().next() { + *view + } else { + doc.ensure_view_init(current_view.id); + current_view.id + }; + + let fmt = if auto_format { + doc.auto_format().map(|fmt| { + let callback = make_format_callback( + doc.id(), + doc.version(), + target_view, + fmt, + Some((None, force)), + ); + jobs.add(Job::with_callback(callback).wait_before_exiting()); + }) + } else { + None + }; + + if fmt.is_none() { + return Some(doc.id()); + } - let fmt = if auto_format { - doc.auto_format().map(|fmt| { - let shared = fmt.shared(); - let callback = make_format_callback( - doc.id(), - doc.version(), - Modified::SetUnmodified, - shared.clone(), - ); - jobs.callback(callback); - shared - }) - } else { None - }; - let future = doc.format_and_save(fmt, force); - jobs.add(Job::new(future).wait_before_exiting()); - } + }) + .collect(); - if quit { - if !force { - buffers_remaining_impl(cx.editor)?; - } + // manually call save for the rest of docs that don't have a formatter + for id in saves { + cx.editor.save::(id, None, force)?; + } - // close all views - let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); - for view_id in views { - cx.editor.close(view_id); - } + if !errors.is_empty() && !force { + bail!("{:?}", errors); } - bail!(errors) + Ok(()) } fn write_all( cx: &mut compositor::Context, - args: &[Cow], + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_all_impl(cx, args, event, false, false) + write_all_impl(cx, false, true) } fn write_all_quit( cx: &mut compositor::Context, - args: &[Cow], + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - write_all_impl(cx, args, event, true, false) + write_all_impl(cx, false, true)?; + quit_all_impl(cx, false) } fn force_write_all_quit( cx: &mut compositor::Context, - args: &[Cow], + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - write_all_impl(cx, args, event, true, true) + let _ = write_all_impl(cx, true, true); + quit_all_impl(cx, true) } -fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { +fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<()> { + cx.block_try_flush_writes()?; if !force { - buffers_remaining_impl(editor)?; + buffers_remaining_impl(cx.editor)?; } // close all views - let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); + let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); for view_id in views { - editor.close(view_id); + cx.editor.close(view_id); } Ok(()) @@ -653,7 +713,7 @@ fn quit_all( return Ok(()); } - quit_all_impl(cx.editor, false) + quit_all_impl(cx, false) } fn force_quit_all( @@ -665,7 +725,7 @@ fn force_quit_all( return Ok(()); } - quit_all_impl(cx.editor, true) + quit_all_impl(cx, true) } fn cquit( @@ -681,9 +741,9 @@ fn cquit( .first() .and_then(|code| code.parse::().ok()) .unwrap_or(1); - cx.editor.exit_code = exit_code; - quit_all_impl(cx.editor, false) + cx.editor.exit_code = exit_code; + quit_all_impl(cx, false) } fn force_cquit( @@ -701,7 +761,7 @@ fn force_cquit( .unwrap_or(1); cx.editor.exit_code = exit_code; - quit_all_impl(cx.editor, true) + quit_all_impl(cx, true) } fn theme( @@ -715,7 +775,10 @@ fn theme( cx.editor.unset_theme_preview(); } PromptEvent::Update => { - if let Some(theme_name) = args.first() { + if args.is_empty() { + // Ensures that a preview theme gets cleaned up if the user backspaces until the prompt is empty. + cx.editor.unset_theme_preview(); + } else if let Some(theme_name) = args.first() { if let Ok(theme) = cx.editor.theme_loader.load(theme_name) { if !(true_color || theme.is_16_color()) { bail!("Unsupported theme: theme requires true color support"); @@ -725,16 +788,21 @@ fn theme( }; } PromptEvent::Validate => { - let theme_name = args.first().with_context(|| "Theme name not provided")?; - let theme = cx - .editor - .theme_loader - .load(theme_name) - .with_context(|| "Theme does not exist")?; - if !(true_color || theme.is_16_color()) { - bail!("Unsupported theme: theme requires true color support"); + if let Some(theme_name) = args.first() { + let theme = cx + .editor + .theme_loader + .load(theme_name) + .map_err(|err| anyhow::anyhow!("Could not load theme: {}", err))?; + if !(true_color || theme.is_16_color()) { + bail!("Unsupported theme: theme requires true color support"); + } + cx.editor.set_theme(theme); + } else { + let name = cx.editor.theme.name().to_string(); + + cx.editor.set_status(name); } - cx.editor.set_theme(theme); } }; @@ -856,8 +924,8 @@ fn replace_selections_with_clipboard_impl( (range.from(), range.to(), Some(contents.as_str().into())) }); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); Ok(()) } Err(e) => Err(e.context("Couldn't get system clipboard contents")), @@ -976,10 +1044,185 @@ fn reload( } let scrolloff = cx.editor.config().scrolloff; + let redraw_handle = cx.editor.redraw_handle.clone(); let (view, doc) = current!(cx.editor); - doc.reload(view.id).map(|_| { - view.ensure_cursor_in_view(doc, scrolloff); - }) + doc.reload(view, &cx.editor.diff_providers, redraw_handle) + .map(|_| { + view.ensure_cursor_in_view(doc, scrolloff); + }) +} + +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]); + + // Ensure that the view is synced with the document's history. + view.sync_changes(doc); + + let redraw_handle = cx.editor.redraw_handle.clone(); + doc.reload(view, &cx.editor.diff_providers, redraw_handle)?; + + 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, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (_view, doc) = current!(cx.editor); + if doc.is_modified() { + write(cx, args, event) + } else { + Ok(()) + } +} + +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], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (_view, doc) = current!(cx.editor); + let config = doc + .language_config() + .context("LSP not defined for the current document")?; + + let scope = config.scope.clone(); + cx.editor.language_servers.restart(config, doc.path())?; + + // This collect is needed because refresh_language_server would need to re-borrow editor. + let document_ids_to_refresh: Vec = cx + .editor + .documents() + .filter_map(|doc| match doc.language_config() { + Some(config) if config.scope.eq(&scope) => Some(doc.id()), + _ => None, + }) + .collect(); + + for document_id in document_ids_to_refresh { + cx.editor.refresh_language_server(document_id); + } + + Ok(()) } fn tree_sitter_scopes( @@ -996,7 +1239,22 @@ fn tree_sitter_scopes( let pos = doc.selection(view.id).primary().cursor(text); let scopes = indent::get_scopes(doc.syntax(), text, pos); - cx.editor.set_status(format!("scopes: {:?}", &scopes)); + + let contents = format!("```json\n{:?}\n````", scopes); + + let callback = async move { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + 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); + }, + )); + Ok(call) + }; + + cx.jobs.callback(callback); + Ok(()) } @@ -1159,18 +1417,41 @@ pub(super) fn goto_line_number( args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { - if event != PromptEvent::Validate { - return Ok(()); + match event { + PromptEvent::Abort => { + if let Some(line_number) = cx.editor.last_line_number { + goto_line_impl(cx.editor, NonZeroUsize::new(line_number)); + let (view, doc) = current!(cx.editor); + view.ensure_cursor_in_view(doc, line_number); + cx.editor.last_line_number = None; + } + return Ok(()); + } + PromptEvent::Validate => { + ensure!(!args.is_empty(), "Line number required"); + cx.editor.last_line_number = None; + } + PromptEvent::Update => { + if args.is_empty() { + if let Some(line_number) = cx.editor.last_line_number { + // When a user hits backspace and there are no numbers left, + // we can bring them back to their original line + goto_line_impl(cx.editor, NonZeroUsize::new(line_number)); + let (view, doc) = current!(cx.editor); + view.ensure_cursor_in_view(doc, line_number); + cx.editor.last_line_number = None; + } + return Ok(()); + } + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let line = doc.selection(view.id).primary().cursor_line(text); + cx.editor.last_line_number.get_or_insert(line + 1); + } } - - ensure!(!args.is_empty(), "Line number required"); - let line = args[0].parse::()?; - goto_line_impl(cx.editor, NonZeroUsize::new(line)); - let (view, doc) = current!(cx.editor); - view.ensure_cursor_in_view(doc, line); Ok(()) } @@ -1314,8 +1595,8 @@ fn sort_impl( .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), ); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); Ok(()) } @@ -1358,8 +1639,8 @@ fn reflow( (range.from(), range.to(), Some(reflowed_text)) }); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); view.ensure_cursor_in_view(doc, scrolloff); Ok(()) @@ -1386,15 +1667,18 @@ fn tree_sitter_subtree( .root_node() .descendant_for_byte_range(from, to) { - let contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); + let mut contents = String::from("```tsq\n"); + helix_core::syntax::pretty_print_tree(&mut contents, selected_node)?; + contents.push_str("\n```"); let callback = async move { - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { 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); - }); + }, + )); Ok(call) }; @@ -1473,13 +1757,30 @@ fn insert_output( Ok(()) } +fn pipe_to( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + pipe_impl(cx, args, event, &ShellBehavior::Ignore) +} + fn pipe(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { + pipe_impl(cx, args, event, &ShellBehavior::Replace) +} + +fn pipe_impl( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, + behavior: &ShellBehavior, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), &ShellBehavior::Replace); + shell(cx, &args.join(" "), behavior); Ok(()) } @@ -1502,8 +1803,8 @@ fn run_shell_command( if !output.is_empty() { let callback = async move { - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new( format!("```sh\n{}\n```", output), editor.syn_loader.clone(), @@ -1512,7 +1813,8 @@ fn run_shell_command( helix_core::Position::new(editor.cursor().0.unwrap_or_default().row, 2), )); compositor.replace_or_push("shell", popup); - }); + }, + )); Ok(call) }; @@ -1725,7 +2027,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "theme", aliases: &[], - doc: "Change the editor theme.", + doc: "Change the editor theme (show current theme if no name specified).", fun: theme, completer: Some(completers::theme), }, @@ -1834,6 +2136,34 @@ 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: &[], + doc: "Write changes only if the file has been modified.", + 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: &[], + doc: "Restarts the Language Server that is in use by the current doc", + fun: lsp_restart, + completer: None, + }, TypableCommand { name: "tree-sitter-scopes", aliases: &[], @@ -1977,7 +2307,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "insert-output", aliases: &[], - doc: "Run shell command, inserting output after each selection.", + doc: "Run shell command, inserting output before each selection.", fun: insert_output, completer: None, }, @@ -1995,6 +2325,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: pipe, completer: None, }, + TypableCommand { + name: "pipe-to", + aliases: &[], + doc: "Pipe each selection to the shell command, ignoring output.", + fun: pipe_to, + completer: None, + }, TypableCommand { name: "run-shell-command", aliases: &["sh"], @@ -2015,7 +2352,10 @@ pub static TYPABLE_COMMAND_MAP: Lazy = Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - // we use .this over split_whitespace() because we care about empty segments - let parts = input.split(' ').collect::>(); + let shellwords = Shellwords::from(input); + let words = shellwords.words(); - // simple heuristic: if there's no just one part, complete command name. - // if there's a space, per command completion kicks in. - if parts.len() <= 1 { + 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| { @@ -2044,18 +2383,29 @@ pub fn command_mode(cx: &mut Context) { .map(|(name, _)| (0.., name.into())) .collect() } else { - 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]) + }) = 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) }) @@ -2081,7 +2431,8 @@ pub 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 c0898dae..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; @@ -27,6 +25,16 @@ pub struct Context<'a> { pub jobs: &'a mut Jobs, } +impl<'a> Context<'a> { + /// Waits on all pending jobs, and then tries to flush all pending write + /// operations for all documents. + pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { + tokio::task::block_in_place(|| helix_lsp::block_on(self.jobs.finish(self.editor, None)))?; + tokio::task::block_in_place(|| helix_lsp::block_on(self.editor.flush_writes()))?; + Ok(()) + } +} + pub trait Component: Any + AnyComponent { /// Process input events, return true if handled. fn handle_event(&mut self, _event: &Event, _ctx: &mut Context) -> EventResult { @@ -65,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") + self.area } - pub fn resize(&mut self, width: u16, height: u16) { - self.terminal - .resize(Rect::new(0, 0, width, height)) - .expect("Unable to resize terminal") - } - - 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) { @@ -193,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/health.rs b/helix-term/src/health.rs index ac9f06fc..6558fe19 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -283,7 +283,7 @@ fn probe_protocol(protocol_name: &str, server_cmd: Option) -> std::io::R if let Some(cmd) = server_cmd { let path = match which::which(&cmd) { Ok(path) => path.display().to_string().green(), - Err(_) => "Not found in $PATH".to_string().red(), + Err(_) => format!("'{}' not found in $PATH", cmd).red(), }; writeln!(stdout, "Binary for {}: {}", protocol_name, path)?; } diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index e5147992..2888b6eb 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -5,7 +5,11 @@ use crate::compositor::Compositor; use futures_util::future::{BoxFuture, Future, FutureExt}; use futures_util::stream::{FuturesUnordered, StreamExt}; -pub type Callback = Box; +pub enum Callback { + EditorCompositor(Box), + Editor(Box), +} + pub type JobFuture = BoxFuture<'static, anyhow::Result>>; pub struct Job { @@ -68,9 +72,10 @@ impl Jobs { ) { match call { Ok(None) => {} - Ok(Some(call)) => { - call(editor, compositor); - } + Ok(Some(call)) => match call { + Callback::EditorCompositor(call) => call(editor, compositor), + Callback::Editor(call) => call(editor), + }, Err(e) => { editor.set_error(format!("Async job failed: {}", e)); } @@ -93,13 +98,32 @@ impl Jobs { } /// Blocks until all the jobs that need to be waited on are done. - pub async fn finish(&mut self) -> anyhow::Result<()> { + pub async fn finish( + &mut self, + editor: &mut Editor, + mut compositor: Option<&mut Compositor>, + ) -> anyhow::Result<()> { log::debug!("waiting on jobs..."); let mut wait_futures = std::mem::take(&mut self.wait_futures); + while let (Some(job), tail) = wait_futures.into_future().await { match job { - Ok(_) => { + Ok(callback) => { wait_futures = tail; + + if let Some(callback) = callback { + // clippy doesn't realize this is an error without the derefs + #[allow(clippy::needless_option_as_deref)] + match callback { + Callback::EditorCompositor(call) if compositor.is_some() => { + call(editor, compositor.as_deref_mut().unwrap()) + } + Callback::Editor(call) => call(editor), + + // skip callbacks for which we don't have the necessary references + _ => (), + } + } } Err(e) => { self.wait_futures = tail; diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 59204889..4a131f0a 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -144,14 +144,70 @@ impl DerefMut for KeyTrieNode { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(untagged)] +#[derive(Debug, Clone, PartialEq)] pub enum KeyTrie { Leaf(MappableCommand), Sequence(Vec), Node(KeyTrieNode), } +impl<'de> Deserialize<'de> for KeyTrie { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(KeyTrieVisitor) + } +} + +struct KeyTrieVisitor; + +impl<'de> serde::de::Visitor<'de> for KeyTrieVisitor { + type Value = KeyTrie; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a command, list of commands, or sub-keymap") + } + + fn visit_str(self, command: &str) -> Result + where + E: serde::de::Error, + { + command + .parse::() + .map(KeyTrie::Leaf) + .map_err(E::custom) + } + + fn visit_seq(self, mut seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + let mut commands = Vec::new(); + while let Some(command) = seq.next_element::<&str>()? { + commands.push( + command + .parse::() + .map_err(serde::de::Error::custom)?, + ) + } + Ok(KeyTrie::Sequence(commands)) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut mapping = HashMap::new(); + let mut order = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + mapping.insert(key, value); + order.push(key); + } + Ok(KeyTrie::Node(KeyTrieNode::new("", mapping, order))) + } +} + impl KeyTrie { pub fn node(&self) -> Option<&KeyTrieNode> { match *self { @@ -334,18 +390,18 @@ impl Keymaps { self.state.push(key); match trie.search(&self.state[1..]) { - Some(&KeyTrie::Node(ref map)) => { + Some(KeyTrie::Node(map)) => { if map.is_sticky { self.state.clear(); self.sticky = Some(map.clone()); } KeymapResult::Pending(map.clone()) } - Some(&KeyTrie::Leaf(ref cmd)) => { + Some(KeyTrie::Leaf(cmd)) => { self.state.clear(); KeymapResult::Matched(cmd.clone()) } - Some(&KeyTrie::Sequence(ref cmds)) => { + Some(KeyTrie::Sequence(cmds)) => { self.state.clear(); KeymapResult::MatchedSequence(cmds.clone()) } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index f07d4028..ef93dee0 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -59,9 +59,9 @@ pub fn default() -> HashMap { ":" => command_mode, "i" => insert_mode, - "I" => prepend_to_line, + "I" => insert_at_line_start, "a" => append_mode, - "A" => append_to_line, + "A" => insert_at_line_end, "o" => open_below, "O" => open_above, @@ -76,6 +76,7 @@ pub fn default() -> HashMap { "s" => select_regex, "A-s" => split_selection_on_newline, + "A-_" => merge_consecutive_selections, "S" => split_selection, ";" => collapse_selection, "A-;" => flip_selections, @@ -100,22 +101,26 @@ pub fn default() -> HashMap { "[" => { "Left bracket" "d" => goto_prev_diag, "D" => goto_first_diag, + "g" => goto_prev_change, + "G" => goto_first_change, "f" => goto_prev_function, - "c" => goto_prev_class, + "t" => goto_prev_class, "a" => goto_prev_parameter, - "o" => goto_prev_comment, - "t" => goto_prev_test, + "c" => goto_prev_comment, + "T" => goto_prev_test, "p" => goto_prev_paragraph, "space" => add_newline_above, }, "]" => { "Right bracket" "d" => goto_next_diag, "D" => goto_last_diag, + "g" => goto_next_change, + "G" => goto_last_change, "f" => goto_next_function, - "c" => goto_next_class, + "t" => goto_next_class, "a" => goto_next_parameter, - "o" => goto_next_comment, - "t" => goto_next_test, + "c" => goto_next_comment, + "T" => goto_next_test, "p" => goto_next_paragraph, "space" => add_newline_below, }, @@ -144,6 +149,7 @@ pub fn default() -> HashMap { "<" => unindent, "=" => format_selections, "J" => join_selections, + "A-J" => join_selections_space, "K" => keep_selections, "A-K" => remove_selections, @@ -197,7 +203,7 @@ pub fn default() -> HashMap { // z family for save/restore/combine from/to sels from register - "tab" => jump_forward, // tab == + "C-i" | "tab" => jump_forward, // tab == "C-o" => jump_backward, "C-s" => save_selection, @@ -208,11 +214,11 @@ pub fn default() -> HashMap { "j" => jumplist_picker, "s" => symbol_picker, "S" => workspace_symbol_picker, - "g" => diagnostics_picker, - "G" => workspace_diagnostics_picker, + "d" => diagnostics_picker, + "D" => workspace_diagnostics_picker, "a" => code_action, "'" => last_picker, - "d" => { "Debug (experimental)" sticky=true + "g" => { "Debug (experimental)" sticky=true "l" => dap_launch, "b" => dap_toggle_breakpoint, "c" => dap_continue, @@ -342,24 +348,27 @@ pub fn default() -> HashMap { let insert = keymap!({ "Insert mode" "esc" => normal_mode, - "backspace" => delete_char_backward, - "C-h" => delete_char_backward, - "del" => delete_char_forward, - "C-d" => delete_char_forward, - "ret" => insert_newline, - "C-j" => insert_newline, - "tab" => insert_tab, - "C-w" => delete_word_backward, - "A-backspace" => delete_word_backward, - "A-d" => delete_word_forward, - "A-del" => delete_word_forward, "C-s" => commit_undo_checkpoint, + "C-x" => completion, + "C-r" => insert_register, - "C-k" => kill_to_line_end, + "C-w" | "A-backspace" => delete_word_backward, + "A-d" | "A-del" => delete_word_forward, "C-u" => kill_to_line_start, + "C-k" => kill_to_line_end, + "C-h" | "backspace" => delete_char_backward, + "C-d" | "del" => delete_char_forward, + "C-j" | "ret" => insert_newline, + "tab" => insert_tab, - "C-x" => completion, - "C-r" => insert_register, + "up" => move_line_up, + "down" => move_line_down, + "left" => move_char_left, + "right" => move_char_right, + "pageup" => page_up, + "pagedown" => page_down, + "home" => goto_line_start, + "end" => goto_line_end_newline, }); hashmap!( Mode::Normal => Keymap::new(normal), diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index d21d3e77..aac5c537 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Error, Result}; use crossterm::event::EventStream; +use helix_loader::VERSION_AND_GIT_HASH; use helix_term::application::Application; use helix_term::args::Args; use helix_term::config::Config; @@ -67,13 +68,14 @@ FLAGS: -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml -c, --config Specifies a file to use for configuration -v Increases logging verbosity each use for up to 3 times + --log Specifies a file to use for logging (default file: {}) -V, --version Prints version information --vsplit Splits all given files vertically into different windows --hsplit Splits all given files horizontally into different windows ", env!("CARGO_PKG_NAME"), - env!("VERSION_AND_GIT_HASH"), + VERSION_AND_GIT_HASH, env!("CARGO_PKG_AUTHORS"), env!("CARGO_PKG_DESCRIPTION"), logpath.display(), @@ -88,7 +90,7 @@ FLAGS: } if args.display_version { - println!("helix {}", env!("VERSION_AND_GIT_HASH")); + println!("helix {}", VERSION_AND_GIT_HASH); std::process::exit(0); } @@ -114,6 +116,7 @@ FLAGS: return Ok(0); } + let logpath = args.log_file.as_ref().cloned().unwrap_or(logpath); setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; let config_dir = helix_loader::config_dir(); @@ -137,8 +140,18 @@ FLAGS: Err(err) => return Err(Error::new(err)), }; + let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| { + eprintln!("Bad language config: {}", err); + eprintln!("Press to continue with default language config"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + helix_core::config::default_syntax_loader() + }); + // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args, config).context("unable to create new application")?; + let mut app = Application::new(args, config, syn_loader_conf) + .context("unable to create new application")?; let exit_code = app.run(&mut EventStream::new()).await?; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 2d7d4f92..11d7886a 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,5 +1,5 @@ use crate::compositor::{Component, Context, Event, EventResult}; -use helix_view::editor::CompleteAction; +use helix_view::{apply_transaction, editor::CompleteAction, ViewId}; use tui::buffer::Buffer as Surface; use tui::text::Spans; @@ -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("") @@ -92,14 +95,19 @@ impl Completion { pub fn new( editor: &Editor, - items: Vec, + mut items: Vec, offset_encoding: helix_lsp::OffsetEncoding, start_offset: usize, trigger_offset: usize, ) -> Self { + // Sort completion items according to their preselect status (given by the LSP server) + items.sort_by_key(|item| !item.preselect.unwrap_or(false)); + + // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { fn item_to_transaction( doc: &Document, + view_id: ViewId, item: &CompletionItem, offset_encoding: helix_lsp::OffsetEncoding, start_offset: usize, @@ -109,13 +117,15 @@ 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()) } }; - util::generate_transaction_from_edits( + util::generate_transaction_from_completion_edit( doc.text(), - vec![edit], + doc.selection(view_id), + edit, offset_encoding, // TODO: should probably transcode in Client ) } else { @@ -124,10 +134,23 @@ impl Completion { // in these cases we need to check for a common prefix and remove it let prefix = Cow::from(doc.text().slice(start_offset..trigger_offset)); let text = text.trim_start_matches::<&str>(&prefix); - Transaction::change( - doc.text(), - vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(), - ) + + // TODO: this needs to be true for the numbers to work out correctly + // in the closure below. It's passed in to a callback as this same + // formula, but can the value change between the LSP request and + // response? If it does, can we recover? + debug_assert!( + doc.selection(view_id) + .primary() + .cursor(doc.text().slice(..)) + == trigger_offset + ); + + Transaction::change_by_selection(doc.text(), doc.selection(view_id), |range| { + let cursor = range.cursor(doc.text().slice(..)); + + (cursor, cursor, Some(text.into())) + }) }; transaction @@ -143,11 +166,11 @@ impl Completion { let (view, doc) = current!(editor); // if more text was entered, remove it - doc.restore(view.id); + doc.restore(view); match event { PromptEvent::Abort => { - doc.restore(view.id); + doc.restore(view); editor.last_completion = None; } PromptEvent::Update => { @@ -156,6 +179,7 @@ impl Completion { let transaction = item_to_transaction( doc, + view.id, item, offset_encoding, start_offset, @@ -164,7 +188,7 @@ impl Completion { // initialize a savepoint doc.savepoint(); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -177,13 +201,14 @@ impl Completion { let transaction = item_to_transaction( doc, + view.id, item, offset_encoding, start_offset, trigger_offset, ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -213,13 +238,13 @@ impl Completion { additional_edits.clone(), offset_encoding, // TODO: should probably transcode in Client ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } } }; }); - let popup = Popup::new(Self::ID, menu); + let popup = Popup::new(Self::ID, menu).with_scrollbar(false); let mut completion = Self { popup, start_offset, @@ -237,21 +262,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 } } @@ -295,6 +312,58 @@ impl Completion { pub fn is_empty(&self) -> bool { 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. ... + // > A typical use case is for example: the `textDocument/completion` request doesn't fill + // > in the `documentation` property for returned completion items since it is expensive + // > to compute. When the item is selected in the user interface then a + // > '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 + 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 + } } impl Component for Completion { @@ -342,7 +411,7 @@ impl Component for Completion { "```{}\n{}\n```\n{}", language, option.detail.as_deref().unwrap_or_default(), - contents.clone() + contents ), cx.editor.syn_loader.clone(), ) @@ -352,15 +421,14 @@ impl Component for Completion { value: contents, })) => { // TODO: set language based on doc scope - Markdown::new( - format!( - "```{}\n{}\n```\n{}", - language, - option.detail.as_deref().unwrap_or_default(), - contents.clone() - ), - cx.editor.syn_loader.clone(), - ) + if let Some(detail) = &option.detail.as_deref() { + Markdown::new( + format!("```{}\n{}\n```\n{}", language, detail, contents), + cx.editor.syn_loader.clone(), + ) + } else { + Markdown::new(contents.to_string(), cx.editor.syn_loader.clone()) + } } None if option.detail.is_some() => { // TODO: copied from above diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 7cb29c3b..35cf77ab 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1,7 +1,8 @@ use crate::{ commands, compositor::{Component, Context, Event, EventResult}, - job, key, + job::{self, Callback}, + key, keymap::{KeymapResult, Keymaps}, ui::{Completion, ProgressSpinners}, }; @@ -13,9 +14,10 @@ use helix_core::{ movement::Direction, syntax::{self, HighlightEvent}, unicode::width::UnicodeWidthStr, - LineEnding, Position, Range, Selection, Transaction, + visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ + apply_transaction, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, @@ -23,7 +25,7 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{borrow::Cow, path::PathBuf}; +use std::{borrow::Cow, cmp::min, num::NonZeroUsize, path::PathBuf}; use tui::buffer::Buffer as Surface; @@ -33,6 +35,7 @@ use super::statusline; pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option>, + pseudo_pending: Vec, last_insert: (commands::MappableCommand, Vec), pub(crate) completion: Option, spinners: ProgressSpinners, @@ -56,6 +59,7 @@ impl EditorView { Self { keymaps, on_next_key: None, + pseudo_pending: Vec::new(), last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, spinners: ProgressSpinners::default(), @@ -75,9 +79,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| { @@ -113,12 +118,22 @@ impl EditorView { } } - if is_focused && editor.config().cursorline { + if is_focused && config.cursorline { Self::highlight_cursorline(doc, view, surface, theme); } + if is_focused && config.cursorcolumn { + Self::highlight_cursorcolumn(doc, view, surface, theme); + } - let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); - let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); + let mut highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); + for diagnostic in Self::doc_diagnostics_highlights(doc, theme) { + // Most of the `diagnostic` Vecs are empty most of the time. Skipping + // a merge for any empty Vec saves a significant amount of work. + if diagnostic.is_empty() { + continue; + } + highlights = Box::new(syntax::merge(highlights, diagnostic)); + } let highlights: Box> = if is_focused { Box::new(syntax::merge( highlights, @@ -127,22 +142,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); @@ -162,7 +169,7 @@ impl EditorView { } } - self.render_diagnostics(doc, view, inner, surface, theme); + Self::render_diagnostics(doc, view, inner, surface, theme); let statusline_area = view .area @@ -213,16 +220,16 @@ impl EditorView { _theme: &Theme, ) -> Box + 'doc> { let text = doc.text().slice(..); - let last_line = std::cmp::min( - // Saturating subs to make it inclusive zero indexing. - (offset.row + height as usize).saturating_sub(1), - doc.text().len_lines().saturating_sub(1), - ); let range = { - // calculate viewport byte ranges - let start = text.line_to_byte(offset.row); - let end = text.line_to_byte(last_line + 1); + // Calculate viewport byte ranges: + // Saturating subs to make it inclusive zero indexing. + let last_line = doc.text().len_lines().saturating_sub(1); + let last_visible_line = (offset.row + height as usize) + .saturating_sub(1) + .min(last_line); + let start = text.line_to_byte(offset.row.min(last_line)); + let end = text.line_to_byte(last_visible_line + 1); start..end }; @@ -262,7 +269,7 @@ impl EditorView { pub fn doc_diagnostics_highlights( doc: &Document, theme: &Theme, - ) -> Vec<(usize, std::ops::Range)> { + ) -> [Vec<(usize, std::ops::Range)>; 5] { use helix_core::diagnostic::Severity; let get_scope_of = |scope| { theme @@ -283,22 +290,38 @@ impl EditorView { let error = get_scope_of("diagnostic.error"); let r#default = get_scope_of("diagnostic"); // this is a bit redundant but should be fine - doc.diagnostics() - .iter() - .map(|diagnostic| { - let diagnostic_scope = match diagnostic.severity { - Some(Severity::Info) => info, - Some(Severity::Hint) => hint, - Some(Severity::Warning) => warning, - Some(Severity::Error) => error, - _ => r#default, - }; - ( - diagnostic_scope, - diagnostic.range.start..diagnostic.range.end, - ) - }) - .collect() + let mut default_vec: Vec<(usize, std::ops::Range)> = Vec::new(); + let mut info_vec = Vec::new(); + let mut hint_vec = Vec::new(); + let mut warning_vec = Vec::new(); + let mut error_vec = Vec::new(); + + for diagnostic in doc.diagnostics() { + // Separate diagnostics into different Vecs by severity. + let (vec, scope) = match diagnostic.severity { + Some(Severity::Info) => (&mut info_vec, info), + Some(Severity::Hint) => (&mut hint_vec, hint), + Some(Severity::Warning) => (&mut warning_vec, warning), + Some(Severity::Error) => (&mut error_vec, error), + _ => (&mut default_vec, r#default), + }; + + // If any diagnostic overlaps ranges with the prior diagnostic, + // merge the two together. Otherwise push a new span. + match vec.last_mut() { + Some((_, range)) if diagnostic.range.start <= range.end => { + // This branch merges overlapping diagnostics, assuming that the current + // diagnostic starts on range.start or later. If this assertion fails, + // we will discard some part of `diagnostic`. This implies that + // `doc.diagnostics()` is not sorted by `diagnostic.range`. + debug_assert!(range.start <= diagnostic.range.start); + range.end = diagnostic.range.end.max(range.end) + } + _ => vec.push((scope, diagnostic.range.start..diagnostic.range.end)), + } + } + + [default_vec, info_vec, hint_vec, warning_vec, error_vec] } /// Get highlight spans for selections in a document view. @@ -399,7 +422,7 @@ impl EditorView { let characters = &whitespace.characters; let mut spans = Vec::new(); - let mut visual_x = 0u16; + let mut visual_x = 0usize; let mut line = 0u16; let tab_width = doc.tab_width(); let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { @@ -436,17 +459,22 @@ impl EditorView { return; } - let starting_indent = (offset.col / tab_width) as u16; - // TODO: limit to a max indent level too. It doesn't cause visual artifacts but it would avoid some - // extra loops if the code is deeply nested. - - for i in starting_indent..(indent_level / tab_width as u16) { - surface.set_string( - viewport.x + (i * tab_width as u16) - offset.col as u16, - viewport.y + line, - &indent_guide_char, - indent_guide_style, - ); + let starting_indent = + (offset.col / tab_width) + config.indent_guides.skip_levels as usize; + + // Don't draw indent guides outside of view + let end_indent = min( + indent_level, + // Add tab_width - 1 to round up, since the first visible + // indent might be a bit after offset.col + offset.col + viewport.width as usize + (tab_width - 1), + ) / tab_width; + + for i in starting_indent..end_indent { + let x = (viewport.x as usize + (i * tab_width) - offset.col) as u16; + let y = viewport.y + line; + debug_assert!(surface.in_bounds(x, y)); + surface.set_string(x, y, &indent_guide_char, indent_guide_style); } }; @@ -488,14 +516,14 @@ impl EditorView { use helix_core::graphemes::{grapheme_width, RopeGraphemes}; for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = visual_x < offset.col as u16 - || visual_x >= viewport.width + offset.col as u16; + let out_of_bounds = offset.col > visual_x + || visual_x >= viewport.width as usize + offset.col; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { // we still want to render an empty cell with the style surface.set_string( - viewport.x + visual_x - offset.col as u16, + (viewport.x as usize + visual_x - offset.col) as u16, viewport.y + line, &newline, style.patch(whitespace_style), @@ -519,7 +547,7 @@ impl EditorView { let (display_grapheme, width) = if grapheme == "\t" { is_whitespace = true; // make sure we display tab as appropriate amount of spaces - let visual_tab_width = tab_width - (visual_x as usize % tab_width); + let visual_tab_width = tab_width - (visual_x % tab_width); let grapheme_tab_width = helix_core::str_utils::char_to_byte_idx(&tab, visual_tab_width); @@ -538,12 +566,12 @@ impl EditorView { (grapheme.as_ref(), width) }; - let cut_off_start = offset.col.saturating_sub(visual_x as usize); + let cut_off_start = offset.col.saturating_sub(visual_x); if !out_of_bounds { // if we're offscreen just keep going until we hit a new line surface.set_string( - viewport.x + visual_x - offset.col as u16, + (viewport.x as usize + visual_x - offset.col) as u16, viewport.y + line, display_grapheme, if is_whitespace { @@ -555,7 +583,7 @@ impl EditorView { } else if cut_off_start != 0 && cut_off_start < width { // partially on screen let rect = Rect::new( - viewport.x as u16, + viewport.x, viewport.y + line, (width - cut_off_start) as u16, 1, @@ -576,7 +604,7 @@ impl EditorView { last_line_indent_level = visual_x; } - visual_x = visual_x.saturating_add(width as u16); + visual_x = visual_x.saturating_add(width); } } } @@ -696,26 +724,34 @@ impl EditorView { let mut offset = 0; let gutter_style = theme.get("ui.gutter"); + let gutter_selected_style = theme.get("ui.gutter.selected"); // 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 mut 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; let y = viewport.y + i as u16; + let gutter_style = if selected { + gutter_selected_style + } else { + gutter_style + }; + 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, @@ -724,12 +760,11 @@ impl EditorView { text.clear(); } - offset += *width as u16; + offset += width as u16; } } pub fn render_diagnostics( - &self, doc: &Document, view: &View, viewport: Rect, @@ -820,6 +855,53 @@ impl EditorView { } } + /// Apply the highlighting on the columns where a cursor is active + pub fn highlight_cursorcolumn( + doc: &Document, + view: &View, + surface: &mut Surface, + theme: &Theme, + ) { + let text = doc.text().slice(..); + + // Manual fallback behaviour: + // ui.cursorcolumn.{p/s} -> ui.cursorcolumn -> ui.cursorline.{p/s} + let primary_style = theme + .try_get_exact("ui.cursorcolumn.primary") + .or_else(|| theme.try_get_exact("ui.cursorcolumn")) + .unwrap_or_else(|| theme.get("ui.cursorline.primary")); + let secondary_style = theme + .try_get_exact("ui.cursorcolumn.secondary") + .or_else(|| theme.try_get_exact("ui.cursorcolumn")) + .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); + + let inner_area = view.inner_area(doc); + let offset = view.offset.col; + + let selection = doc.selection(view.id); + let primary = selection.primary(); + for range in selection.iter() { + let is_primary = primary == *range; + + let Position { row: _, col } = + visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); + // if the cursor is horizontally in the view + if col >= offset && inner_area.width > (col - offset) as u16 { + let area = Rect::new( + inner_area.x + (col - offset) as u16, + view.area.y, + 1, + view.area.height, + ); + if is_primary { + surface.set_style(area, primary_style) + } else { + surface.set_style(area, secondary_style) + } + } + } + } + /// Handle events by looking them up in `self.keymaps`. Returns None /// if event was handled (a command was executed or a subkeymap was /// activated). Only KeymapResult::{NotFound, Cancelled} is returned @@ -831,6 +913,7 @@ impl EditorView { event: KeyEvent, ) -> Option { let mut last_mode = mode; + self.pseudo_pending.extend(self.keymaps.pending()); let key_result = self.keymaps.get(mode, event); cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); @@ -855,9 +938,10 @@ impl EditorView { // TODO: Use an on_mode_change hook to remove signature help cxt.jobs.callback(async { - let call: job::Callback = Box::new(|_editor, compositor| { - compositor.remove(SignatureHelp::ID); - }); + let call: job::Callback = + Callback::EditorCompositor(Box::new(|_editor, compositor| { + compositor.remove(SignatureHelp::ID); + })); Ok(call) }); } @@ -918,37 +1002,40 @@ impl EditorView { } // special handling for repeat operator (key!('.'), _) if self.keymaps.pending().is_empty() => { - // first execute whatever put us into insert mode - self.last_insert.0.execute(cxt); - // then replay the inputs - for key in self.last_insert.1.clone() { - match key { - InsertEvent::Key(key) => self.insert_mode(cxt, key), - InsertEvent::CompletionApply(compl) => { - let (view, doc) = current!(cxt.editor); - - doc.restore(view.id); - - let text = doc.text().slice(..); - let cursor = doc.selection(view.id).primary().cursor(text); - - let shift_position = - |pos: usize| -> usize { pos + cursor - compl.trigger_offset }; - - let tx = Transaction::change( - doc.text(), - compl.changes.iter().cloned().map(|(start, end, t)| { - (shift_position(start), shift_position(end), t) - }), - ); - doc.apply(&tx, view.id); - } - InsertEvent::TriggerCompletion => { - let (_, doc) = current!(cxt.editor); - doc.savepoint(); + for _ in 0..cxt.editor.count.map_or(1, NonZeroUsize::into) { + // first execute whatever put us into insert mode + self.last_insert.0.execute(cxt); + // then replay the inputs + for key in self.last_insert.1.clone() { + match key { + InsertEvent::Key(key) => self.insert_mode(cxt, key), + InsertEvent::CompletionApply(compl) => { + let (view, doc) = current!(cxt.editor); + + doc.restore(view); + + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + let shift_position = + |pos: usize| -> usize { pos + cursor - compl.trigger_offset }; + + let tx = Transaction::change( + doc.text(), + compl.changes.iter().cloned().map(|(start, end, t)| { + (shift_position(start), shift_position(end), t) + }), + ); + apply_transaction(&tx, doc, view); + } + InsertEvent::TriggerCompletion => { + let (_, doc) = current!(cxt.editor); + doc.savepoint(); + } } } } + cxt.editor.count = None; } _ => { // set the count @@ -1005,23 +1092,20 @@ impl EditorView { editor.clear_idle_timer(); // don't retrigger } - pub fn handle_idle_timeout(&mut self, cx: &mut crate::compositor::Context) -> EventResult { - if self.completion.is_some() - || cx.editor.mode != Mode::Insert - || !cx.editor.config().auto_completion - { + pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { + if let Some(completion) = &mut self.completion { + return if completion.ensure_item_resolved(cx) { + EventResult::Consumed(None) + } else { + EventResult::Ignored(None) + }; + } + + if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion { return EventResult::Ignored(None); } - let mut cx = commands::Context { - register: None, - editor: cx.editor, - jobs: cx.jobs, - count: None, - callback: None, - on_next_key_callback: None, - }; - crate::commands::insert::idle_completion(&mut cx); + crate::commands::insert::idle_completion(cx); EventResult::Consumed(None) } @@ -1071,6 +1155,7 @@ impl EditorView { } editor.focus(view_id); + editor.ensure_cursor_in_view(view_id); return EventResult::Consumed(None); } @@ -1107,7 +1192,8 @@ impl EditorView { let primary = selection.primary_mut(); *primary = primary.put_cursor(doc.text().slice(..), pos, true); doc.set_selection(view.id, selection); - + let view_id = view.id; + cxt.editor.ensure_cursor_in_view(view_id); EventResult::Consumed(None) } @@ -1129,6 +1215,7 @@ impl EditorView { commands::scroll(cxt, offset, direction); cxt.editor.tree.focus = current_view; + cxt.editor.ensure_cursor_in_view(current_view); EventResult::Consumed(None) } @@ -1235,7 +1322,7 @@ impl Component for EditorView { // Store a history state if not in insert mode. Otherwise wait till we exit insert // to include any edits to the paste in the history state. if mode != Mode::Insert { - doc.append_changes_to_history(view.id); + doc.append_changes_to_history(view); } EventResult::Consumed(None) @@ -1308,6 +1395,11 @@ impl Component for EditorView { } self.on_next_key = cx.on_next_key_callback.take(); + match self.on_next_key { + Some(_) => self.pseudo_pending.push(key), + None => self.pseudo_pending.clear(), + } + // appease borrowck let callback = cx.callback.take(); @@ -1329,7 +1421,7 @@ impl Component for EditorView { // 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); + doc.append_changes_to_history(view); } } @@ -1337,7 +1429,16 @@ impl Component for EditorView { } Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), - Event::FocusGained | Event::FocusLost => EventResult::Ignored(None), + Event::IdleTimeout => self.handle_idle_timeout(&mut cx), + Event::FocusGained => EventResult::Ignored(None), + Event::FocusLost => { + if context.editor.config().auto_save { + if let Err(e) = commands::typed::write_all_impl(context, false, false) { + context.editor.set_error(format!("{}", e)); + } + } + EventResult::Consumed(None) + } } } @@ -1408,8 +1509,8 @@ impl Component for EditorView { for key in self.keymaps.pending() { disp.push_str(&key.key_sequence_format()); } - if let Some(pseudo_pending) = &cx.editor.pseudo_pending { - disp.push_str(pseudo_pending.as_str()) + for key in &self.pseudo_pending { + disp.push_str(&key.key_sequence_format()); } let style = cx.editor.theme.get("ui.text"); let macro_width = if cx.editor.macro_recording.is_some() { diff --git a/helix-term/src/ui/fuzzy_match.rs b/helix-term/src/ui/fuzzy_match.rs new file mode 100644 index 00000000..e25d7328 --- /dev/null +++ b/helix-term/src/ui/fuzzy_match.rs @@ -0,0 +1,74 @@ +use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; +use fuzzy_matcher::FuzzyMatcher; + +#[cfg(test)] +mod test; + +pub struct FuzzyQuery { + queries: Vec, +} + +impl FuzzyQuery { + pub fn new(query: &str) -> FuzzyQuery { + let mut saw_backslash = false; + let queries = query + .split(|c| { + saw_backslash = match c { + ' ' if !saw_backslash => return true, + '\\' => true, + _ => false, + }; + false + }) + .filter_map(|query| { + if query.is_empty() { + None + } else { + Some(query.replace("\\ ", " ")) + } + }) + .collect(); + FuzzyQuery { queries } + } + + pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option { + // use the rank of the first query for the rank, because merging ranks is not really possible + // this behaviour matches fzf and skim + let score = matcher.fuzzy_match(item, self.queries.get(0)?)?; + if self + .queries + .iter() + .any(|query| matcher.fuzzy_match(item, query).is_none()) + { + return None; + } + Some(score) + } + + pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec)> { + if self.queries.len() == 1 { + return matcher.fuzzy_indices(item, &self.queries[0]); + } + + // use the rank of the first query for the rank, because merging ranks is not really possible + // this behaviour matches fzf and skim + let (score, mut indicies) = matcher.fuzzy_indices(item, self.queries.get(0)?)?; + + // fast path for the common case of not using a space + // during matching this branch should be free thanks to branch prediction + if self.queries.len() == 1 { + return Some((score, indicies)); + } + + for query in &self.queries[1..] { + let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?; + indicies.extend_from_slice(&matched_indicies); + } + + // deadup and remove duplicate matches + indicies.sort_unstable(); + indicies.dedup(); + + Some((score, indicies)) + } +} diff --git a/helix-term/src/ui/fuzzy_match/test.rs b/helix-term/src/ui/fuzzy_match/test.rs new file mode 100644 index 00000000..3f90ef68 --- /dev/null +++ b/helix-term/src/ui/fuzzy_match/test.rs @@ -0,0 +1,47 @@ +use crate::ui::fuzzy_match::FuzzyQuery; +use crate::ui::fuzzy_match::Matcher; + +fn run_test<'a>(query: &str, items: &'a [&'a str]) -> Vec { + let query = FuzzyQuery::new(query); + let matcher = Matcher::default(); + items + .iter() + .filter_map(|item| { + let (_, indicies) = query.fuzzy_indicies(item, &matcher)?; + let matched_string = indicies + .iter() + .map(|&pos| item.chars().nth(pos).unwrap()) + .collect(); + Some(matched_string) + }) + .collect() +} + +#[test] +fn match_single_value() { + let matches = run_test("foo", &["foobar", "foo", "bar"]); + assert_eq!(matches, &["foo", "foo"]) +} + +#[test] +fn match_multiple_values() { + let matches = run_test( + "foo bar", + &["foo bar", "foo bar", "bar foo", "bar", "foo"], + ); + assert_eq!(matches, &["foobar", "foobar", "barfoo"]) +} + +#[test] +fn space_escape() { + let matches = run_test(r"foo\ bar", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["foo bar"]) +} + +#[test] +fn trim() { + let matches = run_test(r" foo bar ", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["barfoo", "foobar", "foobar"]); + let matches = run_test(r" foo bar\ ", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["bar foo"]) +} diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index f2854551..393d24c4 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -68,8 +68,9 @@ impl Component for SignatureHelp { let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); let sig_text_area = area.clip_top(1).with_height(sig_text_height); + let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area); let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false }); - sig_text_para.render(sig_text_area.inner(&margin), surface); + sig_text_para.render(sig_text_area, surface); if self.signature_doc.is_none() { return; diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 1d247b1a..b9c1f9de 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -40,7 +40,7 @@ impl Item for PathBuf { type Data = PathBuf; fn label(&self, root_path: &Self::Data) -> Spans { - self.strip_prefix(&root_path) + self.strip_prefix(root_path) .unwrap_or(self) .to_string_lossy() .into() @@ -77,11 +77,12 @@ impl Menu { editor_data: ::Data, callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static, ) -> Self { - let mut menu = Self { + let matches = (0..options.len()).map(|i| (i, 0)).collect(); + Self { options, editor_data, matcher: Box::new(Matcher::default()), - matches: Vec::new(), + matches, cursor: None, widths: Vec::new(), callback_fn: Box::new(callback_fn), @@ -89,12 +90,7 @@ impl Menu { size: (0, 0), viewport: (0, 0), recalculate: true, - }; - - // TODO: scoring on empty input should just use a fastpath - menu.score(""); - - menu + } } pub fn score(&mut self, pattern: &str) { @@ -105,17 +101,15 @@ impl Menu { .iter() .enumerate() .filter_map(|(index, option)| { - let text: String = option.filter_text(&self.editor_data).into(); + let text = option.filter_text(&self.editor_data); // TODO: using fuzzy_indices could give us the char idx for match highlighting self.matcher .fuzzy_match(&text, pattern) .map(|score| (index, score)) }), ); - // matches.sort_unstable_by_key(|(_, score)| -score); - self.matches.sort_unstable_by_key(|(index, _score)| { - self.options[*index].sort_text(&self.editor_data) - }); + // Order of equal elements needs to be preserved as LSP preselected items come in order of high to low priority + self.matches.sort_by_key(|(_, score)| -score); // reset cursor position self.cursor = None; @@ -213,6 +207,14 @@ impl Menu { }) } + pub fn selection_mut(&mut self) -> Option<&mut T> { + self.cursor.and_then(|cursor| { + self.matches + .get(cursor) + .map(|(index, _score)| &mut self.options[*index]) + }) + } + pub fn is_empty(&self) -> bool { self.matches.is_empty() } @@ -222,6 +224,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 { @@ -318,11 +331,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) @@ -355,20 +363,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 60ad3b24..ade1d8cf 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,5 +1,6 @@ mod completion; pub(crate) mod editor; +mod fuzzy_match; mod info; pub mod lsp; mod markdown; @@ -12,11 +13,13 @@ mod spinner; mod statusline; mod text; +use crate::compositor::{Component, Compositor}; +use crate::job::{self, Callback}; pub use completion::Completion; pub use editor::EditorView; pub use markdown::Markdown; pub use menu::Menu; -pub use picker::{FileLocation, FilePicker, Picker}; +pub use picker::{DynamicPicker, FileLocation, FilePicker, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; @@ -24,7 +27,7 @@ pub use text::Text; use helix_core::regex::Regex; use helix_core::regex::RegexBuilder; -use helix_view::{Document, Editor, View}; +use helix_view::Editor; use std::path::PathBuf; @@ -59,7 +62,7 @@ pub fn regex_prompt( prompt: std::borrow::Cow<'static, str>, history_register: Option, completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, - fun: impl Fn(&mut View, &mut Document, Regex, PromptEvent) + 'static, + fun: impl Fn(&mut Editor, Regex, PromptEvent) + 'static, ) { let (view, doc) = current!(cx.editor); let doc_id = view.doc; @@ -106,11 +109,42 @@ pub fn regex_prompt( view.jumps.push((doc_id, snapshot.clone())); } - fun(view, doc, regex, event); + fun(cx.editor, regex, event); + let (view, doc) = current!(cx.editor); view.ensure_cursor_in_view(doc, config.scrolloff); } - Err(_err) => (), // TODO: mark command line as error + Err(err) => { + let (view, doc) = current!(cx.editor); + doc.set_selection(view.id, snapshot.clone()); + view.offset = offset_snapshot; + + if event == PromptEvent::Validate { + let callback = async move { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |_editor: &mut Editor, compositor: &mut Compositor| { + let contents = Text::new(format!("{}", err)); + let size = compositor.size(); + let mut popup = Popup::new("invalid-regex", contents) + .position(Some(helix_core::Position::new( + size.height as usize - 2, // 2 = statusline + commandline + 0, + ))) + .auto_close(true); + popup.required_size((size.width, size.height)); + + compositor.replace_or_push("invalid-regex", popup); + }, + )); + Ok(call) + }; + + cx.jobs.callback(callback); + } else { + // Update + // TODO: mark command line as error + } + } } } } @@ -173,13 +207,14 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi // Cap the number of files if we aren't in a git project, preventing // hangs when using the picker in your home directory - let files: Vec<_> = if root.join(".git").is_dir() { + let mut files: Vec = if root.join(".git").exists() { files.collect() } else { // const MAX: usize = 8192; const MAX: usize = 100_000; files.take(MAX).collect() }; + files.sort(); log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); @@ -196,7 +231,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)), ) } @@ -220,8 +255,8 @@ pub mod completers { pub fn buffer(editor: &Editor, input: &str) -> Vec { let mut names: Vec<_> = editor .documents - .iter() - .map(|(_id, doc)| { + .values() + .map(|doc| { let name = doc .relative_path() .map(|p| p.display().to_string()) @@ -356,6 +391,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/overlay.rs b/helix-term/src/ui/overlay.rs index 0b8a93ae..5b2bc806 100644 --- a/helix-term/src/ui/overlay.rs +++ b/helix-term/src/ui/overlay.rs @@ -69,4 +69,8 @@ impl Component for Overlay { let dimensions = (self.calc_child_size)(area); self.content.cursor(dimensions, ctx) } + + fn id(&self) -> Option<&'static str> { + self.content.id() + } } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 24d3b288..aad3f81c 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,41 +1,67 @@ use crate::{ compositor::{Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, - ui::{self, EditorView}, + ui::{self, fuzzy_match::FuzzyQuery, EditorView}, }; +use futures_util::future::BoxFuture; use tui::{ buffer::Buffer as Surface, widgets::{Block, BorderType, Borders}, }; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; -use fuzzy_matcher::FuzzyMatcher; use tui::widgets::Widget; -use std::time::Instant; use std::{ - cmp::Reverse, - collections::HashMap, - io::Read, - path::{Path, PathBuf}, + cmp::{self, 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; +use super::{menu::Item, overlay::Overlay}; 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, @@ -114,52 +140,82 @@ 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]) + } + PathOrId::Id(id) => { + let doc = editor.documents.get(&id).unwrap(); + Preview::EditorDocument(doc) + } } + } - 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) - } + 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)| 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, }, - ) - .unwrap_or(CachedPreview::NotFound); - self.preview_cache.insert(path.to_owned(), preview); - Preview::Cached(&self.preview_cache[path]) + }); + + // Then attempt to highlight it if it has no language set + if let Some(doc) = doc { + if doc.language_config().is_none() { + let loader = cx.editor.syn_loader.clone(); + doc.detect_language(loader); + } + } + + EventResult::Consumed(None) } } @@ -205,7 +261,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 => { @@ -228,8 +284,14 @@ impl Component for FilePicker { let offset = Position::new(first_line, 0); - let highlights = + let mut highlights = EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme); + for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) { + if spans.is_empty() { + continue; + } + highlights = Box::new(helix_core::syntax::merge(highlights, spans)); + } EditorView::render_text_highlights( doc, offset, @@ -261,6 +323,9 @@ impl Component for FilePicker { } fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { + if let Event::IdleTimeout = event { + return self.handle_idle_timeout(ctx); + } // TODO: keybinds for scrolling preview self.picker.handle_event(event, ctx) } @@ -280,15 +345,37 @@ impl Component for FilePicker { } } +#[derive(PartialEq, Eq, Debug)] +struct PickerMatch { + score: i64, + index: usize, + len: usize, +} + +impl PickerMatch { + fn key(&self) -> impl Ord { + (cmp::Reverse(self.score), self.len, self.index) + } +} + +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.key().cmp(&other.key()) + } +} + pub struct Picker { options: Vec, editor_data: T::Data, // filter: String, matcher: Box, - /// (index, score) - matches: Vec<(usize, i64)>, - /// Filter over original options. - filters: Vec, // could be optimized into bit but not worth it now + matches: Vec, /// Current height of the completions box completion_height: u16, @@ -323,7 +410,6 @@ impl Picker { editor_data, matcher: Box::new(Matcher::default()), matches: Vec::new(), - filters: Vec::new(), cursor: 0, prompt, previous_pattern: String::new(), @@ -335,13 +421,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 } @@ -358,68 +447,71 @@ 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) { - // TODO: remove when retain_mut is in stable rust - #[allow(unused_imports, deprecated)] - use retain_mut::RetainMut; - + 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. - #[allow(unstable_name_collisions)] - 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 self.matcher.fuzzy_match(&text, pattern) { + 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 { - self.matches.clear(); - self.matches.extend( - self.options - .iter() - .enumerate() - .filter_map(|(index, option)| { - // filter options first before matching - if !self.filters.is_empty() { - // TODO: this filters functionality seems inefficient, - // instead store and operate on filters if any - self.filters.binary_search(&index).ok()?; - } - - let text = option.filter_text(&self.editor_data); - - self.matcher - .fuzzy_match(&text, pattern) - .map(|score| (index, score)) - }), - ); - self.matches - .sort_unstable_by_key(|(_, score)| Reverse(*score)); + self.force_score(); } log::debug!("picker score {:?}", Instant::now().duration_since(now)); // reset cursor position self.cursor = 0; + let pattern = self.prompt.line(); self.previous_pattern.clone_from(pattern); } + pub fn force_score(&mut self) { + let pattern = self.prompt.line(); + + let query = FuzzyQuery::new(pattern); + self.matches.clear(); + self.matches.extend( + self.options + .iter() + .enumerate() + .filter_map(|(index, option)| { + let text = option.filter_text(&self.editor_data); + + query + .fuzzy_match(&text, &self.matcher) + .map(|score| PickerMatch { + index, + score, + len: text.chars().count(), + }) + }), + ); + + self.matches.sort_unstable(); + } + /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`) pub fn move_by(&mut self, amount: usize, direction: Direction) { let len = self.matches.len(); @@ -462,15 +554,7 @@ impl Picker { pub fn selection(&self) -> Option<&T> { self.matches .get(self.cursor) - .map(|(index, _score)| &self.options[*index]) - } - - pub fn save_filter(&mut self, cx: &Context) { - self.filters.clear(); - self.filters - .extend(self.matches.iter().map(|(index, _)| *index)); - self.filters.sort_unstable(); // used for binary search later - self.prompt.clear(cx.editor); + .map(|pmatch| &self.options[pmatch.index]) } pub fn toggle_preview(&mut self) { @@ -510,6 +594,9 @@ impl Component for Picker { compositor.last_picker = compositor.pop(); }))); + // So that idle timeout retriggers + cx.editor.reset_idle_timer(); + match key_event { shift!(Tab) | key!(Up) | ctrl!('p') => { self.move_by(1, Direction::Backward); @@ -550,9 +637,6 @@ impl Component for Picker { } return close_fn; } - ctrl!(' ') => { - self.save_filter(cx); - } ctrl!('t') => { self.toggle_preview(); } @@ -617,7 +701,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); @@ -635,9 +719,8 @@ impl Component for Picker { } let spans = option.label(&self.editor_data); - let (_score, highlights) = self - .matcher - .fuzzy_indices(&String::from(&spans), self.prompt.line()) + let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) + .fuzzy_indicies(&String::from(&spans), &self.matcher) .unwrap_or_default(); spans.0.into_iter().fold(inner, |pos, span| { @@ -676,3 +759,78 @@ impl Component for Picker { self.prompt.cursor(area, editor) } } + +/// Returns a new list of options to replace the contents of the picker +/// when called with the current picker query, +pub type DynQueryCallback = + Box BoxFuture<'static, anyhow::Result>>>; + +/// A picker that updates its contents via a callback whenever the +/// query string changes. Useful for live grep, workspace symbols, etc. +pub struct DynamicPicker { + file_picker: FilePicker, + query_callback: DynQueryCallback, + query: String, +} + +impl DynamicPicker { + pub const ID: &'static str = "dynamic-picker"; + + pub fn new(file_picker: FilePicker, query_callback: DynQueryCallback) -> Self { + Self { + file_picker, + query_callback, + query: String::new(), + } + } +} + +impl Component for DynamicPicker { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + self.file_picker.render(area, surface, cx); + } + + fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { + let event_result = self.file_picker.handle_event(event, cx); + let current_query = self.file_picker.picker.prompt.line(); + + if !matches!(event, Event::IdleTimeout) || self.query == *current_query { + return event_result; + } + + self.query.clone_from(current_query); + + let new_options = (self.query_callback)(current_query.to_owned(), cx.editor); + + cx.jobs.callback(async move { + let new_options = new_options.await?; + let callback = + crate::job::Callback::EditorCompositor(Box::new(move |editor, compositor| { + // Wrapping of pickers in overlay is done outside the picker code, + // so this is fragile and will break if wrapped in some other widget. + let picker = match compositor.find_id::>>(Self::ID) { + Some(overlay) => &mut overlay.content.file_picker.picker, + None => return, + }; + picker.options = new_options; + picker.cursor = 0; + picker.force_score(); + editor.reset_idle_timer(); + })); + anyhow::Ok(callback) + }); + EventResult::Consumed(None) + } + + fn cursor(&self, area: Rect, ctx: &Editor) -> (Option, CursorKind) { + self.file_picker.cursor(area, ctx) + } + + fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + self.file_picker.required_size(viewport) + } + + fn id(&self) -> Option<&'static str> { + Some(Self::ID) + } +} 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 d66e32be..5fb6745a 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -31,7 +31,7 @@ pub struct Prompt { next_char_handler: Option, } -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub enum PromptEvent { /// The prompt input has been updated. Update, @@ -293,27 +293,28 @@ impl Prompt { register: char, direction: CompletionDirection, ) { - let register = cx.editor.registers.get_mut(register).read(); - - if register.is_empty() { - return; - } + (self.callback_fn)(cx, &self.line, PromptEvent::Abort); + 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); self.move_end(); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); self.recalculate_completion(cx.editor); } @@ -351,6 +352,7 @@ impl Prompt { let prompt_color = theme.get("ui.text"); let completion_color = theme.get("ui.menu"); let selected_color = theme.get("ui.menu.selected"); + let suggestion_color = theme.get("ui.text.inactive"); // completion let max_len = self @@ -402,7 +404,7 @@ impl Prompt { surface.set_stringn( area.x + col * (1 + col_width), area.y + row, - &completion, + completion, col_width.saturating_sub(1) as usize, color, ); @@ -449,21 +451,29 @@ impl Prompt { // render buffer text surface.set_string(area.x, area.y + line, &self.prompt, prompt_color); - let input: Cow = if self.line.is_empty() { + let (input, is_suggestion): (Cow, bool) = if self.line.is_empty() { // latest value in the register list - self.history_register + match self + .history_register .and_then(|reg| cx.editor.registers.last(reg)) .map(|entry| entry.into()) - .unwrap_or_else(|| Cow::from("")) + { + Some(value) => (value, true), + None => (Cow::from(""), false), + } } else { - self.line.as_str().into() + (self.line.as_str().into(), false) }; surface.set_string( area.x + self.prompt.len() as u16, area.y + line, &input, - prompt_color, + if is_suggestion { + suggestion_color + } else { + prompt_color + }, ); } } @@ -546,10 +556,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()); }; } @@ -564,13 +571,11 @@ impl Component for Prompt { ctrl!('p') | key!(Up) => { if let Some(register) = self.history_register { self.change_history(cx, register, CompletionDirection::Backward); - (self.callback_fn)(cx, &self.line, PromptEvent::Update); } } ctrl!('n') | key!(Down) => { if let Some(register) = self.history_register { self.change_history(cx, register, CompletionDirection::Forward); - (self.callback_fn)(cx, &self.line, PromptEvent::Update); } } key!(Tab) => { diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 365e1ca9..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,9 +144,14 @@ 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, helix_view::editor::StatusLineElement::Separator => render_separator, helix_view::editor::StatusLineElement::Spacer => render_spacer, } @@ -154,23 +162,24 @@ where F: Fn(&mut RenderContext, String, Option