diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 157343618bf4..0d6fcb3e8426 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: --workspace --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/release.yml b/.github/workflows/release.yml index e7aca89bb72a..9518a5373ccb 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 56d85751b940..dc91c9ff33a0 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 f0bf11f2b686..066af52830ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # 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" @@ -13,11 +19,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +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", ] @@ -33,15 +51,24 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.66" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "arc-swap" -version = "1.5.1" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + +[[package]] +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "983cd8b9d4b02a6dc6ffa557262eb5858a27a0038ffffe21a0f133eaa819a164" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] [[package]] name = "autocfg" @@ -66,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" @@ -80,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 = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" + +[[package]] +name = "bytesize" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" +checksum = "6c58ec36aac5066d5ca17df51b3e70279f5670a72102f5752cb7e7c856adfc70" [[package]] name = "cassowary" @@ -90,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.74" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581f5dba903aac52ea3feb5ec4810848460ee833876f1f9b0fdeab1f19091574" +checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" [[package]] name = "cfg-if" @@ -115,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", @@ -127,15 +190,42 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "4.4.2" +version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ab1b92798304eedc095b53942963240037c0516452cb11aeba709d420b2219" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" dependencies = [ "error-code", "str-buf", "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" @@ -151,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]] @@ -172,7 +270,7 @@ dependencies = [ "futures-core", "libc", "mio", - "parking_lot", + "parking_lot 0.12.1", "signal-hook", "signal-hook-mio", "winapi", @@ -187,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" @@ -197,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" @@ -271,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" @@ -288,15 +485,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" +checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" [[package]] name = "futures-executor" -version = "0.3.25" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e" dependencies = [ "futures-core", "futures-task", @@ -305,41 +502,543 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.25" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366" + +[[package]] +name = "futures-util" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" +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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9e5fd7bc63ad527d64584f8d01f99b89c051f5fbb8144b58ae5f812775065cf" +dependencies = [ + "bstr 1.0.1", + "btoi", + "git-date", + "itoa", + "nom", + "quick-error", +] + +[[package]] +name = "git-attributes" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8013dfce47c1e29236d732308933e2c77af5355ec5105755d26faf7764d3f7b" +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 = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" +checksum = "3090baa2f4a3fe488a9b3e31090b83259aaf930bf0634af34c18117274f8f1a8" +dependencies = [ + "thiserror", +] + +[[package]] +name = "git-command" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215145cc1686a45bc6f9872b153a0d3f3c40a1b94173a928325e1b53dfa5e2af" +dependencies = [ + "bstr 1.0.1", +] + +[[package]] +name = "git-config" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9da662fd64ac69772158dcf04777da6266f0f36bc9a310b3eb2d805bb696315" +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 = "989a90c1c630513a153c685b4249b96fdf938afc75bf7ef2ae1ccbd3d799f5db" +dependencies = [ + "bitflags", + "bstr 1.0.1", + "git-path", + "libc", + "thiserror", +] + +[[package]] +name = "git-credentials" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97cd6bbe001afd6356b35ef13f2a6b0f0abc0133d1b2ecaec1033bdd769616d6" +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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "412c9b89026505bd24d5f8acafa578de6eea3b271ece307a73b8e646e671302a" +dependencies = [ + "bstr 1.0.1", + "itoa", + "thiserror", + "time", +] + +[[package]] +name = "git-diff" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca87474422d26d606d04cec6bedfabcd92a0a74102cd7936785358ced6a4a25a" +dependencies = [ + "git-hash", + "git-object", + "imara-diff", + "thiserror", +] + +[[package]] +name = "git-discover" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9e26e0bc434643228cd418185bd28ca5c7cf831bde1da434807391c27ac40e" +dependencies = [ + "bstr 1.0.1", + "git-hash", + "git-path", + "git-ref", + "git-sec", + "thiserror", +] + +[[package]] +name = "git-features" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ff74064fa007c5beefa89a64bb72834f32b3c497750a56c79c6802bbdb311f9" +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.2", +] + +[[package]] +name = "git-index" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485da97dd4f69c7d9a8dc238cd6f4a726387ffc34573489e8e0d2bee266e3454" +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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0316b4346f3e162ade368209efb8a609b587793c74aa3b8de0ec01a4f3580120" +dependencies = [ + "bstr 1.0.1", + "git-actor", + "quick-error", +] + +[[package]] +name = "git-object" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8563e2d6f524d7053f3106714f99ecdc3adbba2cb7108c09d71a02579f2e19" +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.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616115a0e3daff6e08842758d24547b37a6eb6d0e2eedd95a740c3aaa2750333" +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.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd16b88f4b66041f41ca510c28bd81c4ee7363c5a544b3d62b4170432965871" +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.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6767925a6fc4af5c5a81e348d1d851c1b3ab2b512bd7f562ac11be37c14468" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf310ed5f2829ac0af96e7d4aebd4ae4b89f0718a7ae3666d09b02b2c5a1dfd" +dependencies = [ + "bstr 1.0.1", + "git-hash", + "git-revision", + "git-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "git-repository" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993277960cb7e2d3991a11c1ec6951c1d142de052c26a18d2db64304e52d3741" +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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9a6bd28c9d1676bb96f428cd09614ae18a0087d7cea1cebfd177e25f99b2af" +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 = "git-traverse" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd356da21ec00f69b9d4f105df4cb85543c746b18f4b7fc81529ce77713cdb29" +dependencies = [ + "git-hash", + "git-hashtable", + "git-object", + "thiserror", +] [[package]] -name = "futures-util" -version = "0.3.25" +name = "git-url" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" +checksum = "c85af407ed0dbb8d8da2a7241827d2fd5681186d9dab3570fc8dd8d6152ec48f" 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.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +checksum = "ca3bc63878f134e08ed52dba5d82422798c01a3f2e48c38ae9a2f7ff9194f362" 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]] @@ -349,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", @@ -371,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", @@ -385,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", @@ -400,19 +1099,31 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.6", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.3", ] [[package]] name = "helix-core" version = "0.6.0" dependencies = [ + "ahash 0.8.3", "arc-swap", "bitflags", "chrono", "encoding_rs", "etcetera", + "hashbrown 0.13.2", "helix-loader", + "imara-diff", "log", "once_cell", "quickcheck", @@ -420,7 +1131,6 @@ dependencies = [ "ropey", "serde", "serde_json", - "similar", "slotmap", "smallvec", "smartstring", @@ -471,6 +1181,7 @@ dependencies = [ "futures-executor", "futures-util", "helix-core", + "helix-loader", "log", "lsp-types", "serde", @@ -500,6 +1211,7 @@ dependencies = [ "helix-loader", "helix-lsp", "helix-tui", + "helix-vcs", "helix-view", "ignore", "indoc", @@ -532,6 +1244,19 @@ dependencies = [ "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" @@ -548,6 +1273,8 @@ dependencies = [ "helix-loader", "helix-lsp", "helix-tui", + "helix-vcs", + "libc", "log", "once_cell", "serde", @@ -569,20 +1296,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" @@ -611,11 +1369,31 @@ 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.3", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indoc" -version = "1.0.7" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" +checksum = "6fe2b9d82064e8a0226fddb3547f37f28eaa46d0fc210e275d835f08cf3b76a7" [[package]] name = "instant" @@ -626,17 +1404,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", ] @@ -649,9 +1437,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.132" +version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" [[package]] name = "libloading" @@ -663,11 +1451,20 @@ dependencies = [ "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", @@ -703,18 +1500,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", @@ -722,6 +1534,37 @@ 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 = "nom8" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8" +dependencies = [ + "memchr", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -743,19 +1586,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.16.0" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" + +[[package]] +name = "parking_lot" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] [[package]] name = "parking_lot" @@ -764,14 +1627,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", @@ -800,13 +1677,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 = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8c414345b4a98cbcd0e8d8829c8f54b47a7ed4fb771c45b7c5c6c0ae23dc4c" +dependencies = [ + "bytesize", + "dashmap", + "human_format", + "parking_lot 0.11.2", +] + [[package]] name = "pulldown-cmark" version = "0.9.2" @@ -818,6 +1707,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" @@ -847,9 +1742,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", ] @@ -876,9 +1771,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" +checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" dependencies = [ "aho-corasick", "memchr", @@ -893,9 +1788,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" @@ -908,14 +1803,20 @@ dependencies = [ [[package]] name = "ropey" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd22239fafefc42138ca5da064f3c17726a80d2379d817a3521240e78dd0064" +checksum = "a4f832915525613e83f275694cb8c184f5df13ca26a9ef0ea6ce736921964c8e" 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" @@ -937,20 +1838,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.147" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.147" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" dependencies = [ "proc-macro2", "quote", @@ -959,9 +1866,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.87" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa", "ryu", @@ -979,6 +1886,21 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +dependencies = [ + "serde", +] + +[[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" @@ -1021,12 +1943,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" @@ -1098,9 +2014,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", @@ -1121,6 +2037,15 @@ 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" @@ -1143,18 +2068,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" dependencies = [ "proc-macro2", "quote", @@ -1179,6 +2104,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" @@ -1196,9 +2150,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.21.2" +version = "1.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099" +checksum = "597a12a59981d9e3c38d216785b0c37399f6e415e8d0712047620f189371b0bb" dependencies = [ "autocfg", "bytes", @@ -1206,12 +2160,12 @@ dependencies = [ "memchr", "mio", "num_cpus", - "parking_lot", + "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "winapi", + "windows-sys", ] [[package]] @@ -1238,13 +2192,38 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.9" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772c1426ab886e7362aedf4abc9c0d1348a979517efedfc25862944d10137af0" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90a238ee2e6ede22fb95350acc78e21dc40da00bb66c0334bde83de4ed89424e" +dependencies = [ + "indexmap", + "nom8", + "serde", + "serde_spanned", + "toml_datetime", +] + [[package]] name = "tree-sitter" version = "0.20.9" @@ -1270,6 +2249,12 @@ 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.6.0" @@ -1278,9 +2263,9 @@ 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" @@ -1288,15 +2273,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" dependencies = [ - "hashbrown", + "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", ] @@ -1350,9 +2335,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", @@ -1360,9 +2345,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", @@ -1375,9 +2360,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", @@ -1385,9 +2370,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", @@ -1398,15 +2383,15 @@ 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" -version = "4.3.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c831fbbee9e129a8cf93e7747a82da9d95ba8e16621cae60ec2cdc849bacb7b" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" dependencies = [ "either", "libc", @@ -1444,48 +2429,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 9e985ddcd1c4..ecf6848e04a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "helix-lsp", "helix-dap", "helix-loader", + "helix-vcs", "xtask", ] diff --git a/README.md b/README.md index c1f30218f82e..60125e11800b 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) @@ -38,7 +52,7 @@ If you would like to build from source: ```shell git clone https://github.com/helix-editor/helix cd helix -cargo install --path helix-term +cargo install --locked --path helix-term ``` This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`. @@ -70,7 +84,7 @@ 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`, +> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --locked --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 @@ -98,6 +112,7 @@ If installing from source, to use Helix in desktop environments that supports [X ```bash cp contrib/Helix.desktop ~/.local/share/applications +cp contrib/helix.png ~/.local/share/icons ``` To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`: @@ -107,8 +122,6 @@ sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.deskt 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: @@ -126,3 +139,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 b9ed4c22ac55..e70b3aebd5b7 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 63fc2f790e72..268a38df61bd 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 2277a0bd9dc6..9835145cea3f 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 8713c3ffe85d..7514a3d0fcc3 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -46,7 +46,7 @@ on unix operating systems. | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `cursorline` | Highlight all lines with a cursor. | `false` | | `cursorcolumn` | Highlight all columns with a cursor. | `false` | -| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers"]` | +| `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` | @@ -97,11 +97,13 @@ The following statusline elements can be configured: | `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-base-name` | The basename 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 | @@ -113,6 +115,7 @@ The following statusline elements can be configured: | Key | Description | Default | | --- | ----------- | ------- | +| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` | | `display-messages` | Display LSP progress messages below statusline[^1] | `false` | | `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | | `display-signature-help-docs` | Display docs under signature help popup | `true` | @@ -148,6 +151,8 @@ All git related options are only enabled in a git repository. | Key | Description | Default | |--|--|---------| |`hidden` | Enables ignoring hidden files. | true +|`follow-links` | Follow symlinks instead of ignoring them | true +|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | true |`parents` | Enables reading ignore files from parent directories. | true |`ignore` | Enables reading `.ignore` files. | true |`git-ignore` | Enables reading `.gitignore` files. | true @@ -214,7 +219,7 @@ Options for rendering whitespace with visible characters. Use `:set whitespace.r | Key | Description | Default | |-----|-------------|---------| -| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `tab`, and `newline`. | `"none"` | +| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `nbsp`, `tab`, and `newline`. | `"none"` | | `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below | Example @@ -254,3 +259,76 @@ render = true character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽" skip-levels = 1 ``` + +### `[editor.gutters]` Section + +For simplicity, `editor.gutters` accepts an array of gutter types, which will +use default settings for all gutter components. + +```toml +[editor] +gutters = ["diff", "diagnostics", "line-numbers", "spacer"] +``` + +To customize the behavior of gutters, the `[editor.gutters]` section must +be used. This section contains top level settings, as well as settings for +specific gutter components as sub-sections. + +| Key | Description | Default | +| --- | --- | --- | +| `layout` | A vector of gutters to display | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | + +Example: + +```toml +[editor.gutters] +layout = ["diff", "diagnostics", "line-numbers", "spacer"] +``` + +#### `[editor.gutters.line-numbers]` Section + +Options for the line number gutter + +| Key | Description | Default | +| --- | --- | --- | +| `min-width` | The minimum number of characters to use | `3` | + +Example: + +```toml +[editor.gutters.line-numbers] +min-width = 1 +``` + +#### `[editor.gutters.diagnotics]` Section + +Currently unused + +#### `[editor.gutters.diff]` Section + +Currently unused + +#### `[editor.gutters.spacer]` Section + +Currently unused + +### `[editor.soft-wrap]` Section + +Options for soft wrapping lines that exceed the view width + +| Key | Description | Default | +| --- | --- | --- | +| `enable` | Whether soft wrapping is enabled. | `false` | +| `max-wrap` | Maximum free space left at the end of the line. | `20` | +| `max-indent-retain` | Maximum indentation to carry over when soft wrapping a line. | `40` | +| `wrap-indicator` | Text inserted before soft wrapped lines, highlighted with `ui.virtual.wrap` | `↪ ` | + +Example: + +```toml +[editor.soft-wrap] +enable = true +max-wrap = 25 # increase value to reduce forced mid-word wrapping +max-indent-retain = 0 +wrap-indicator = "" # set wrap-indicator to "" to hide it +``` diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 086cd3c68f43..99fe82b33159 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -2,29 +2,34 @@ | --- | --- | --- | --- | --- | | 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 | ✓ | | | | +| dhall | ✓ | ✓ | | `dhall-lsp-server` | | diff | ✓ | | | | | dockerfile | ✓ | | | `docker-langserver` | | dot | ✓ | | | `dot-language-server` | | edoc | ✓ | | | | | eex | ✓ | | | | | ejs | ✓ | | | | -| elixir | ✓ | ✓ | | `elixir-ls` | +| elixir | ✓ | ✓ | ✓ | `elixir-ls` | | elm | ✓ | | | `elm-language-server` | | elvish | ✓ | | | `elvish` | | env | ✓ | | | | @@ -48,14 +53,14 @@ | gowork | ✓ | | | `gopls` | | graphql | ✓ | | | | | hare | ✓ | | | | -| haskell | ✓ | | | `haskell-language-server-wrapper` | +| haskell | ✓ | ✓ | | `haskell-language-server-wrapper` | | hcl | ✓ | | ✓ | `terraform-ls` | | heex | ✓ | ✓ | | `elixir-ls` | | html | ✓ | | | `vscode-html-language-server` | | idris | | | | `idris2-lsp` | | iex | ✓ | | | | | ini | ✓ | | | | -| java | ✓ | | | `jdtls` | +| java | ✓ | ✓ | | `jdtls` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | jsdoc | ✓ | | | | | json | ✓ | | ✓ | `vscode-json-language-server` | @@ -74,10 +79,13 @@ | make | ✓ | | | | | markdown | ✓ | | | `marksman` | | markdown.inline | ✓ | | | | +| matlab | ✓ | | | | +| mermaid | ✓ | | | | | meson | ✓ | | ✓ | | | mint | | | | `mint` | +| msbuild | ✓ | | ✓ | | | nickel | ✓ | | ✓ | `nls` | -| nix | ✓ | | | `rnix-lsp` | +| nix | ✓ | | | `nil` | | nu | ✓ | | | | | ocaml | ✓ | | ✓ | `ocamllsp` | | ocaml-interface | ✓ | | | `ocamllsp` | @@ -87,19 +95,22 @@ | pascal | ✓ | ✓ | | `pasls` | | perl | ✓ | ✓ | ✓ | | | php | ✓ | ✓ | ✓ | `intelephense` | +| ponylang | ✓ | ✓ | ✓ | | | prisma | ✓ | | | `prisma-language-server` | | prolog | | | | `swipl` | | protobuf | ✓ | | ✓ | | | purescript | ✓ | | | `purescript-language-server` | | python | ✓ | ✓ | ✓ | `pylsp` | +| qml | ✓ | | ✓ | `qmlls` | | r | ✓ | | | `R` | -| racket | | | | `racket` | +| racket | ✓ | | | `racket` | | regex | ✓ | | | | | rescript | ✓ | ✓ | | `rescript-language-server` | | rmarkdown | ✓ | | ✓ | `R` | | ron | ✓ | | ✓ | | | ruby | ✓ | ✓ | ✓ | `solargraph` | | rust | ✓ | ✓ | ✓ | `rust-analyzer` | +| sage | ✓ | ✓ | | | | scala | ✓ | | ✓ | `metals` | | scheme | ✓ | | | | | scss | ✓ | | | `vscode-css-language-server` | @@ -113,14 +124,14 @@ | swift | ✓ | | | `sourcekit-lsp` | | tablegen | ✓ | ✓ | ✓ | | | task | ✓ | | | | -| tfvars | | | | `terraform-ls` | +| tfvars | ✓ | | ✓ | `terraform-ls` | | toml | ✓ | | | `taplo` | | tsq | ✓ | | | | | tsx | ✓ | ✓ | ✓ | `typescript-language-server` | | twig | ✓ | | | | | typescript | ✓ | ✓ | ✓ | `typescript-language-server` | | ungrammar | ✓ | | | | -| v | ✓ | | | `vls` | +| v | ✓ | | | `v` | | vala | ✓ | | | `vala-language-server` | | verilog | ✓ | ✓ | | `svlangserver` | | vhs | ✓ | | | | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index b7496d338c4e..0ff501a33fdb 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -43,7 +43,9 @@ | `:change-current-directory`, `:cd` | Change the current working directory. | | `:show-directory`, `:pwd` | Show the current working directory. | | `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. | +| `:character-info`, `:char` | Get info about the character under the primary cursor. | | `: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 | @@ -70,4 +72,5 @@ | `: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/install.md b/book/src/install.md index 44f13584e631..792799d7a7f9 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -69,17 +69,21 @@ choco install helix **MSYS2:** +Choose the [proper command](https://www.msys2.org/docs/package-naming/) for your system from below: + + - For 32 bit Windows 7 or above: + ``` pacman -S mingw-w64-i686-helix ``` -or + - For 64 bit Windows 7 or above: ``` pacman -S mingw-w64-x86_64-helix ``` -or + - For 64 bit Windows 8.1 or above: ``` pacman -S mingw-w64-ucrt-x86_64-helix @@ -90,11 +94,19 @@ pacman -S mingw-w64-ucrt-x86_64-helix ``` git clone https://github.com/helix-editor/helix cd helix -cargo install --path helix-term +cargo install --path helix-term --locked ``` This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`. +If you are using the musl-libc instead of glibc the following environment variable must be set during the build +to ensure tree sitter grammars can be loaded correctly: + +``` +RUSTFLAGS="-C target-feature=-crt-static" +``` + + 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. @@ -123,7 +135,7 @@ 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`, +> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term --locked`, > tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`. If you plan on keeping the repo locally, an alternative to copying/symlinking diff --git a/book/src/keymap.md b/book/src/keymap.md index 6523b09fb88c..0550e57f3f3b 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -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` | @@ -165,6 +166,9 @@ These sub-modes are accessible from normal mode and typically switch back to nor | `Ctrl-w` | Enter [window mode](#window-mode) | N/A | | `Space` | Enter [space mode](#space-mode) | N/A | +These modes (except command mode) can be configured by +[remapping keys](https://docs.helix-editor.com/remapping.html#minor-modes). + #### View mode Accessed by typing `z` in [normal mode](#normal-mode). @@ -297,31 +301,35 @@ 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). | Key | Description | Command | | ----- | ----------- | ------- | -| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` | | `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` | -| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` | +| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` | | `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | +| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_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` | +| `]g` | Go to next change | `goto_next_change` | +| `[g` | Go to previous change | `goto_prev_change` | +| `]G` | Go to last change | `goto_last_change` | +| `[G` | Go to first change | `goto_first_change` | | `]Space` | Add newline below | `add_newline_below` | +| `[Space` | Add newline above | `add_newline_above` | ## Insert mode diff --git a/book/src/languages.md b/book/src/languages.md index 133e6447941c..0646b9af9580 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -35,11 +35,11 @@ Each language is configured by adding a `[[language]]` section to a [[language]] name = "mylang" scope = "source.mylang" -injection-regex = "^mylang$" +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"] } ``` @@ -61,7 +61,7 @@ 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 | +| `max-line-length` | Maximum line length. Used for the `:reflow` command and soft-wrapping | ### File-type detection and the `file-types` key @@ -99,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 e89c66113d1b..8339e05fce8c 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -25,6 +25,32 @@ 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. +## Minor modes + +Minor modes are accessed by pressing a key (usually from normal mode), giving access to dedicated bindings. Bindings +can be modified or added by nesting definitions. + +```toml +[keys.insert.j] +k = "normal_mode" # Maps `jk` to exit insert mode + +[keys.normal.g] +a = "code_action" # Maps `ga` to show possible code actions + +# invert `j` and `k` in view mode +[keys.normal.z] +j = "scroll_up" +k = "scroll_down" + +# create a new minor mode bound to `+` +[keys.normal."+"] +m = ":run-shell-command make" +c = ":run-shell-command cargo build" +t = ":run-shell-command cargo test" +``` + +## Special keys and modifiers + Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes `C-`, `S-` and `A-`. Special keys are encoded as follows: diff --git a/book/src/themes.md b/book/src/themes.md index 392b5f8c956a..0d0827fd18e3 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -103,7 +103,7 @@ Some styles might not be supported by your terminal emulator. | `line` | | `curl` | | `dashed` | -| `dot` | +| `dotted` | | `double_line` | @@ -140,6 +140,8 @@ We use a similar set of scopes as - `type` - Types - `builtin` - Primitive types provided by the language (`int`, `usize`) + - `enum` + - `variant` - `constructor` - `constant` (TODO: constant.other.placeholder for %v) @@ -202,6 +204,8 @@ We use a similar set of scopes as - `namespace` +- `special` + - `markup` - `heading` - `marker` @@ -248,10 +252,14 @@ These scopes are used for theming the editor interface. | `ui.background` | | | `ui.background.separator` | Picker separator below input line | | `ui.cursor` | | +| `ui.cursor.normal` | | | `ui.cursor.insert` | | | `ui.cursor.select` | | | `ui.cursor.match` | Matching bracket etc. | | `ui.cursor.primary` | Cursor with primary selection | +| `ui.cursor.primary.normal` | | +| `ui.cursor.primary.insert` | | +| `ui.cursor.primary.select` | | | `ui.gutter` | Gutter | | `ui.gutter.selected` | Gutter for the line the cursor is on | | `ui.linenr` | Line numbers | @@ -268,6 +276,7 @@ These scopes are used for theming the editor interface. | `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 | diff --git a/book/src/usage.md b/book/src/usage.md index 646bf926d536..a6eb9ec1d4f1 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -143,6 +143,7 @@ though, we climb the syntax tree and then take the previous selection. So | `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/css/variables.css b/book/theme/css/variables.css index 1bf91b19aeda..5d0978cc3e5f 100644 --- a/book/theme/css/variables.css +++ b/book/theme/css/variables.css @@ -48,6 +48,18 @@ --searchresults-border-color: #888; --searchresults-li-bg: #252932; --search-mark-bg: #e3b171; + --hljs-background: #191f26; + --hljs-color: #e6e1cf; + --hljs-quote: #5c6773; + --hljs-variable: #ff7733; + --hljs-type: #ffee99; + --hljs-title: #b8cc52; + --hljs-symbol: #ffb454; + --hljs-selector-tag: #ff7733; + --hljs-selector-tag: #36a3d9; + --hljs-selector-tag: #00568d; + --hljs-selector-tag: #91b362; + --hljs-selector-tag: #d96c75; } .coal { @@ -88,6 +100,18 @@ --searchresults-border-color: #98a3ad; --searchresults-li-bg: #2b2b2f; --search-mark-bg: #355c7d; + --hljs-background: #969896; + --hljs-color: #cc6666; + --hljs-quote: #de935f; + --hljs-variable: #f0c674; + --hljs-type: #b5bd68; + --hljs-title: #8abeb7; + --hljs-symbol: #81a2be; + --hljs-selector-tag: #b294bb; + --hljs-selector-tag: #1d1f21; + --hljs-selector-tag: #c5c8c6; + --hljs-selector-tag: #718c00; + --hljs-selector-tag: #c82829; } .light { @@ -128,6 +152,14 @@ --searchresults-border-color: #888; --searchresults-li-bg: #e4f2fe; --search-mark-bg: #a2cff5; + --hljs-background: #f6f7f6; + --hljs-color: #000; + --hljs-quote: #575757; + --hljs-variable: #d70025; + --hljs-type: #b21e00; + --hljs-title: #0030f2; + --hljs-symbol: #008200; + --hljs-selector-tag: #9d00ec; } .navy { @@ -168,6 +200,19 @@ --searchresults-border-color: #5c5c68; --searchresults-li-bg: #242430; --search-mark-bg: #a2cff5; + + --hljs-background: #969896; + --hljs-color: #cc6666; + --hljs-quote: #de935f; + --hljs-variable: #f0c674; + --hljs-type: #b5bd68; + --hljs-title: #8abeb7; + --hljs-symbol: #81a2be; + --hljs-selector-tag: #b294bb; + --hljs-selector-tag: #1d1f21; + --hljs-selector-tag: #c5c8c6; + --hljs-selector-tag: #718c00; + --hljs-selector-tag: #c82829; } .rust { @@ -208,6 +253,14 @@ --searchresults-border-color: #888; --searchresults-li-bg: #dec2a2; --search-mark-bg: #e69f67; + --hljs-background: #f6f7f6; + --hljs-color: #000; + --hljs-quote: #575757; + --hljs-variable: #d70025; + --hljs-type: #b21e00; + --hljs-title: #0030f2; + --hljs-symbol: #008200; + --hljs-selector-tag: #9d00ec; } @media (prefers-color-scheme: dark) { @@ -292,7 +345,15 @@ --searchresults-header-fg: #5f5f71; --searchresults-border-color: #5c5c68; --searchresults-li-bg: #242430; - --search-mark-bg: #a2cff5; + --search-mark-bg: #acff5; + --hljs-background: #2f1e2e; + --hljs-color: #a39e9b; + --hljs-quote: #8d8687; + --hljs-variable: #ef6155; + --hljs-type: #f99b15; + --hljs-title: #fec418; + --hljs-symbol: #48b685; + --hljs-selector-tag: #815ba4; } .colibri { @@ -338,5 +399,13 @@ --searchresults-border-color: #5c5c68; --searchresults-li-bg: #242430; --search-mark-bg: #a2cff5; + --hljs-background: #TODO; + --hljs-color: #TODO; + --hljs-quote: #TODO; + --hljs-variable: #TODO; + --hljs-type: #TODO; + --hljs-title: #TODO; + --hljs-symbol: #TODO; + --hljs-selector-tag: #TODO; */ } diff --git a/book/theme/favicon.png b/book/theme/favicon.png index a5b1aa16c4dc..1baa7c517245 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 90e0ea58bdb1..05dd73a8afb9 100644 --- a/book/theme/favicon.svg +++ b/book/theme/favicon.svg @@ -1,22 +1 @@ - - - - - + \ No newline at end of file diff --git a/book/theme/highlight.css b/book/theme/highlight.css index 8dce7d65f5bd..a2db0500af48 100644 --- a/book/theme/highlight.css +++ b/book/theme/highlight.css @@ -7,12 +7,12 @@ code.hljs { padding:3px 5px } .hljs { - background:#2f1e2e; - color:#a39e9b + background: var(--hljs-background); + color: var(--hljs-color); } .hljs-comment, .hljs-quote { - color:#8d8687 + color: var(--hljs-quote) } .hljs-link, .hljs-meta, @@ -23,7 +23,7 @@ code.hljs { .hljs-tag, .hljs-template-variable, .hljs-variable { - color:#ef6155 + color: var(--hljs-variable) } .hljs-built_in, .hljs-deletion, @@ -31,22 +31,22 @@ code.hljs { .hljs-number, .hljs-params, .hljs-type { - color:#f99b15 + color: var(--hljs-type) } .hljs-attribute, .hljs-section, .hljs-title { - color:#fec418 + color: var(--hljs-title) } .hljs-addition, .hljs-bullet, .hljs-string, .hljs-symbol { - color:#48b685 + color: var(--hljs-symbol) } .hljs-keyword, .hljs-selector-tag { - color:#815ba4 + color: var(--hljs-selector-tag) } .hljs-emphasis { font-style:italic diff --git a/contrib/Helix.appdata.xml b/contrib/Helix.appdata.xml new file mode 100644 index 000000000000..a242849751cd --- /dev/null +++ b/contrib/Helix.appdata.xml @@ -0,0 +1,87 @@ + + + com.helix_editor.Helix + CC0-1.0 + MPL-2.0 + Helix + A post-modern text editor + + +

+ Helix is a terminal-based text editor inspired by Kakoune / Neovim and written in Rust. +

+
    +
  • Vim-like modal editing
  • +
  • Multiple selections
  • +
  • Built-in language server support
  • +
  • Smart, incremental syntax highlighting and code editing via tree-sitter
  • +
+
+ + Helix.desktop + + + + Helix with default theme + https://github.com/helix-editor/helix/raw/d4565b4404cabc522bd60822abd374755581d751/screenshot.png + + + + https://helix-editor.com/ + https://opencollective.com/helix-editor + https://docs.helix-editor.com/ + https://github.com/helix-editor/helix + https://github.com/helix-editor/helix/issues + + + + + + https://helix-editor.com/news/release-22-12-highlights/ + + + https://helix-editor.com/news/release-22-08-highlights/ + + + https://helix-editor.com/news/release-22-05-highlights/ + + + https://helix-editor.com/news/release-22-03-highlights/ + + + + + keyboard + + + + Utility + TextEditor + + + + text + editor + development + programming + + + + hx + text/english + text/plain + text/x-makefile + text/x-c++hdr + text/x-c++src + text/x-chdr + text/x-csrc + text/x-java + text/x-moc + text/x-pascal + text/x-tcl + text/x-tex + application/x-shellscript + text/x-c + text/x-c++ + +
diff --git a/contrib/helix.png b/contrib/helix.png index bef00b98418a..a9b699a46069 100644 Binary files a/contrib/helix.png and b/contrib/helix.png differ diff --git a/docs/releases.md b/docs/releases.md index ec0b72704c44..6e7c37c6ebe5 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -5,6 +5,7 @@ Helix releases are versioned in the Calendar Versioning scheme: we'll use `` as a placeholder for the tag being published. * Merge the changelog PR +* Add new `` entry in `contrib/Helix.appdata.xml` with release information according to the [AppStream spec](https://www.freedesktop.org/software/appstream/docs/sect-Metadata-Releases.html) * Tag and push * `git tag -s -m "" -a && git push` * Make sure to switch to master and pull first diff --git a/flake.lock b/flake.lock index 74206d2b3dd9..4cf1018c4999 100644 --- a/flake.lock +++ b/flake.lock @@ -1,30 +1,13 @@ { "nodes": { - "all-cabal-json": { - "flake": false, - "locked": { - "lastModified": 1665552503, - "narHash": "sha256-r14RmRSwzv5c+bWKUDaze6pXM7nOsiz1H8nvFHJvufc=", - "owner": "nix-community", - "repo": "all-cabal-json", - "rev": "d7c0434eebffb305071404edcf9d5cd99703878e", - "type": "github" - }, - "original": { - "owner": "nix-community", - "ref": "hackage", - "repo": "all-cabal-json", - "type": "github" - } - }, "crane": { "flake": false, "locked": { - "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": { @@ -52,47 +35,49 @@ "dream2nix": { "inputs": { "alejandra": [ - "nci", - "nixpkgs" + "nci" + ], + "all-cabal-json": [ + "nci" ], - "all-cabal-json": "all-cabal-json", "crane": "crane", "devshell": [ "nci", "devshell" ], + "flake-parts": "flake-parts", "flake-utils-pre-commit": [ - "nci", - "nixpkgs" + "nci" + ], + "ghc-utils": [ + "nci" ], - "ghc-utils": "ghc-utils", "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": 1667429039, - "narHash": "sha256-Lu6da25JioHzerkLHAHSO9suCQFzJ/XBjkcGCIbasLM=", + "lastModified": 1671323629, + "narHash": "sha256-9KHTPjIDjfnzZ4NjpE3gGIVHVHopy6weRDYO/7Y3hF8=", "owner": "nix-community", "repo": "dream2nix", - "rev": "5252794e58eedb02d607fa3187ffead7becc81b0", + "rev": "2d7d68505c8619410df2c6b6463985f97cbcba6e", "type": "github" }, "original": { @@ -101,6 +86,24 @@ "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": 1659877975, @@ -116,22 +119,6 @@ "type": "github" } }, - "ghc-utils": { - "flake": false, - "locked": { - "lastModified": 1662774800, - "narHash": "sha256-1Rd2eohGUw/s1tfvkepeYpg8kCEXiIot0RijapUjAkE=", - "ref": "refs/heads/master", - "rev": "bb3a2d3dc52ff0253fb9c2812bd7aa2da03e0fea", - "revCount": 1072, - "type": "git", - "url": "https://gitlab.haskell.org/bgamari/ghc-utils" - }, - "original": { - "type": "git", - "url": "https://gitlab.haskell.org/bgamari/ghc-utils" - } - }, "nci": { "inputs": { "devshell": "devshell", @@ -144,11 +131,11 @@ ] }, "locked": { - "lastModified": 1667542401, - "narHash": "sha256-mdWjP5tjSf8n6FAtpSgL23kX4+eWBwLrSYo9iY3mA8Q=", + "lastModified": 1671430291, + "narHash": "sha256-UIc7H8F3N8rK72J/Vj5YJdV72tvDvYjH+UPsOFvlcsE=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "cd5e5cbd81c80dc219455dd3b1e0ddb55fae51ec", + "rev": "b1b0d38b8c3b0d0e6a38638d5bbe10b0bc67522c", "type": "github" }, "original": { @@ -159,11 +146,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1667482890, - "narHash": "sha256-pua0jp87iwN7NBY5/ypx0s9L9CG49Ju/NI4wGwurHc4=", + "lastModified": 1671359686, + "narHash": "sha256-3MpC6yZo+Xn9cPordGz2/ii6IJpP2n8LE8e/ebUXLrs=", "owner": "nixos", "repo": "nixpkgs", - "rev": "a2a777538d971c6b01c6e54af89ddd6567c055e8", + "rev": "04f574a1c0fde90b51bf68198e2297ca4e7cccf4", "type": "github" }, "original": { @@ -173,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", @@ -188,11 +193,11 @@ ] }, "locked": { - "lastModified": 1667487142, - "narHash": "sha256-bVuzLs1ZVggJAbJmEDVO9G6p8BH3HRaolK70KXvnWnU=", + "lastModified": 1671416426, + "narHash": "sha256-kpSH1Jrxfk2qd0pRPJn1eQdIOseGv5JuE+YaOrqU9s4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "cf668f737ac986c0a89e83b6b2e3c5ddbd8cf33b", + "rev": "fbaaff24f375ac25ec64268b0a0d63f91e474b7d", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b1d3f01eb681..673f3cf603d2 100644 --- a/flake.nix +++ b/flake.nix @@ -82,7 +82,8 @@ packages = with common.pkgs; [lld_13 cargo-flamegraph rust-analyzer] ++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin) - ++ (lib.optional stdenv.isLinux lldb); + ++ (lib.optional stdenv.isLinux lldb) + ++ (lib.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreFoundation); env = [ { name = "HELIX_RUNTIME"; @@ -94,10 +95,10 @@ } { name = "RUSTFLAGS"; - value = + eval = if common.pkgs.stdenv.isLinux - then "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment" - else ""; + then "$RUSTFLAGS\" -C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment\"" + else "$RUSTFLAGS"; } ]; }; @@ -150,6 +151,7 @@ ["languages.toml" "theme.toml" "base16_theme.toml"] } ''; + checkPhase = ":"; meta.mainProgram = "hx"; }; @@ -166,7 +168,7 @@ packages // { helix-unwrapped = packages.helix.passthru.unwrapped; - helix-unwrapped-debug = packages.helix-debug.passthru.unwrapped; + helix-unwrapped-dev = packages.helix-dev.passthru.unwrapped; } ) outputs.packages; diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 45272f980058..10de738f80b8 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -17,7 +17,7 @@ integration = [] [dependencies] helix-loader = { version = "0.6", path = "../helix-loader" } -ropey = { version = "1.5", default-features = false, features = ["simd"] } +ropey = { version = "1.5.1", default-features = false, features = ["simd"] } smallvec = "1.10" smartstring = "1.0.1" unicode-segmentation = "1.10" @@ -26,17 +26,19 @@ unicode-general-category = "0.6" # slab = "0.4.2" slotmap = "1.0" tree-sitter = "0.20" -once_cell = "1.16" +once_cell = "1.17" arc-swap = "1" regex = "1" bitflags = "1.3" +ahash = "0.8.3" +hashbrown = { version = "0.13.2", features = ["raw"] } log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -toml = "0.5" +toml = "0.7" -similar = "2.2" +imara-diff = "0.1.0" encoding_rs = "0.8" diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index 072c93d014c0..31f9d3649314 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -17,7 +17,7 @@ pub const DEFAULT_PAIRS: &[(char, char)] = &[ ]; /// The type that represents the collection of auto pairs, -/// keyed by the opener. +/// keyed by both opener and closer. #[derive(Debug, Clone)] pub struct AutoPairs(HashMap); diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index db1f2da9c313..6b5da17ef56f 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -45,4 +45,5 @@ pub struct Diagnostic { 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 6960c679c4ac..a5d6d72298be 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/doc_formatter.rs b/helix-core/src/doc_formatter.rs new file mode 100644 index 000000000000..c7dc9081f5cf --- /dev/null +++ b/helix-core/src/doc_formatter.rs @@ -0,0 +1,384 @@ +//! The `DocumentFormatter` forms the bridge between the raw document text +//! and onscreen positioning. It yields the text graphemes as an iterator +//! and traverses (part) of the document text. During that traversal it +//! handles grapheme detection, softwrapping and annotations. +//! It yields `FormattedGrapheme`s and their corresponding visual coordinates. +//! +//! As both virtual text and softwrapping can insert additional lines into the document +//! it is generally not possible to find the start of the previous visual line. +//! Instead the `DocumentFormatter` starts at the last "checkpoint" (usually a linebreak) +//! called a "block" and the caller must advance it as needed. + +use std::borrow::Cow; +use std::fmt::Debug; +use std::mem::{replace, take}; + +#[cfg(test)] +mod test; + +use unicode_segmentation::{Graphemes, UnicodeSegmentation}; + +use crate::graphemes::{Grapheme, GraphemeStr}; +use crate::syntax::Highlight; +use crate::text_annotations::TextAnnotations; +use crate::{Position, RopeGraphemes, RopeSlice}; + +/// TODO make Highlight a u32 to reduce the size of this enum to a single word. +#[derive(Debug, Clone, Copy)] +pub enum GraphemeSource { + Document { + codepoints: u32, + }, + /// Inline virtual text can not be highlighted with a `Highlight` iterator + /// because it's not part of the document. Instead the `Highlight` + /// is emitted right by the document formatter + VirtualText { + highlight: Option, + }, +} + +#[derive(Debug, Clone)] +pub struct FormattedGrapheme<'a> { + pub grapheme: Grapheme<'a>, + pub source: GraphemeSource, +} + +impl<'a> FormattedGrapheme<'a> { + pub fn new( + g: GraphemeStr<'a>, + visual_x: usize, + tab_width: u16, + source: GraphemeSource, + ) -> FormattedGrapheme<'a> { + FormattedGrapheme { + grapheme: Grapheme::new(g, visual_x, tab_width), + source, + } + } + /// Returns whether this grapheme is virtual inline text + pub fn is_virtual(&self) -> bool { + matches!(self.source, GraphemeSource::VirtualText { .. }) + } + + pub fn placeholder() -> Self { + FormattedGrapheme { + grapheme: Grapheme::Other { g: " ".into() }, + source: GraphemeSource::Document { codepoints: 0 }, + } + } + + pub fn doc_chars(&self) -> usize { + match self.source { + GraphemeSource::Document { codepoints } => codepoints as usize, + GraphemeSource::VirtualText { .. } => 0, + } + } + + pub fn is_whitespace(&self) -> bool { + self.grapheme.is_whitespace() + } + + pub fn width(&self) -> usize { + self.grapheme.width() + } + + pub fn is_word_boundary(&self) -> bool { + self.grapheme.is_word_boundary() + } +} + +#[derive(Debug, Clone)] +pub struct TextFormat { + pub soft_wrap: bool, + pub tab_width: u16, + pub max_wrap: u16, + pub max_indent_retain: u16, + pub wrap_indicator: Box, + pub wrap_indicator_highlight: Option, + pub viewport_width: u16, +} + +// test implementation is basically only used for testing or when softwrap is always disabled +impl Default for TextFormat { + fn default() -> Self { + TextFormat { + soft_wrap: false, + tab_width: 4, + max_wrap: 3, + max_indent_retain: 4, + wrap_indicator: Box::from(" "), + viewport_width: 17, + wrap_indicator_highlight: None, + } + } +} + +#[derive(Debug)] +pub struct DocumentFormatter<'t> { + text_fmt: &'t TextFormat, + annotations: &'t TextAnnotations, + + /// The visual position at the end of the last yielded word boundary + visual_pos: Position, + graphemes: RopeGraphemes<'t>, + /// The character pos of the `graphemes` iter used for inserting annotations + char_pos: usize, + /// The line pos of the `graphemes` iter used for inserting annotations + line_pos: usize, + exhausted: bool, + + /// Line breaks to be reserved for virtual text + /// at the next line break + virtual_lines: usize, + inline_anntoation_graphemes: Option<(Graphemes<'t>, Option)>, + + // softwrap specific + /// The indentation of the current line + /// Is set to `None` if the indentation level is not yet known + /// because no non-whitespace graphemes have been encountered yet + indent_level: Option, + /// In case a long word needs to be split a single grapheme might need to be wrapped + /// while the rest of the word stays on the same line + peeked_grapheme: Option<(FormattedGrapheme<'t>, usize)>, + /// A first-in first-out (fifo) buffer for the Graphemes of any given word + word_buf: Vec>, + /// The index of the next grapheme that will be yielded from the `word_buf` + word_i: usize, +} + +impl<'t> DocumentFormatter<'t> { + /// Creates a new formatter at the last block before `char_idx`. + /// A block is a chunk which always ends with a linebreak. + /// This is usually just a normal line break. + /// However very long lines are always wrapped at constant intervals that can be cheaply calculated + /// to avoid pathological behaviour. + pub fn new_at_prev_checkpoint( + text: RopeSlice<'t>, + text_fmt: &'t TextFormat, + annotations: &'t TextAnnotations, + char_idx: usize, + ) -> (Self, usize) { + // TODO divide long lines into blocks to avoid bad performance for long lines + let block_line_idx = text.char_to_line(char_idx.min(text.len_chars())); + let block_char_idx = text.line_to_char(block_line_idx); + annotations.reset_pos(block_char_idx); + ( + DocumentFormatter { + text_fmt, + annotations, + visual_pos: Position { row: 0, col: 0 }, + graphemes: RopeGraphemes::new(text.slice(block_char_idx..)), + char_pos: block_char_idx, + exhausted: false, + virtual_lines: 0, + indent_level: None, + peeked_grapheme: None, + word_buf: Vec::with_capacity(64), + word_i: 0, + line_pos: block_line_idx, + inline_anntoation_graphemes: None, + }, + block_char_idx, + ) + } + + fn next_inline_annotation_grapheme(&mut self) -> Option<(&'t str, Option)> { + loop { + if let Some(&mut (ref mut annotation, highlight)) = + self.inline_anntoation_graphemes.as_mut() + { + if let Some(grapheme) = annotation.next() { + return Some((grapheme, highlight)); + } + } + + if let Some((annotation, highlight)) = + self.annotations.next_inline_annotation_at(self.char_pos) + { + self.inline_anntoation_graphemes = Some(( + UnicodeSegmentation::graphemes(&*annotation.text, true), + highlight, + )) + } else { + return None; + } + } + } + + fn advance_grapheme(&mut self, col: usize) -> Option> { + let (grapheme, source) = + if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme() { + (grapheme.into(), GraphemeSource::VirtualText { highlight }) + } else if let Some(grapheme) = self.graphemes.next() { + self.virtual_lines += self.annotations.annotation_lines_at(self.char_pos); + let codepoints = grapheme.len_chars() as u32; + + let overlay = self.annotations.overlay_at(self.char_pos); + let grapheme = match overlay { + Some((overlay, _)) => overlay.grapheme.as_str().into(), + None => Cow::from(grapheme).into(), + }; + + self.char_pos += codepoints as usize; + (grapheme, GraphemeSource::Document { codepoints }) + } else { + if self.exhausted { + return None; + } + self.exhausted = true; + // EOF grapheme is required for rendering + // and correct position computations + return Some(FormattedGrapheme { + grapheme: Grapheme::Other { g: " ".into() }, + source: GraphemeSource::Document { codepoints: 0 }, + }); + }; + + let grapheme = FormattedGrapheme::new(grapheme, col, self.text_fmt.tab_width, source); + + Some(grapheme) + } + + /// Move a word to the next visual line + fn wrap_word(&mut self, virtual_lines_before_word: usize) -> usize { + // softwrap this word to the next line + let indent_carry_over = if let Some(indent) = self.indent_level { + if indent as u16 <= self.text_fmt.max_indent_retain { + indent as u16 + } else { + 0 + } + } else { + // ensure the indent stays 0 + self.indent_level = Some(0); + 0 + }; + + self.visual_pos.col = indent_carry_over as usize; + self.virtual_lines -= virtual_lines_before_word; + self.visual_pos.row += 1 + virtual_lines_before_word; + let mut i = 0; + let mut word_width = 0; + let wrap_indicator = UnicodeSegmentation::graphemes(&*self.text_fmt.wrap_indicator, true) + .map(|g| { + i += 1; + let grapheme = FormattedGrapheme::new( + g.into(), + self.visual_pos.col + word_width, + self.text_fmt.tab_width, + GraphemeSource::VirtualText { + highlight: self.text_fmt.wrap_indicator_highlight, + }, + ); + word_width += grapheme.width(); + grapheme + }); + self.word_buf.splice(0..0, wrap_indicator); + + for grapheme in &mut self.word_buf[i..] { + let visual_x = self.visual_pos.col + word_width; + grapheme + .grapheme + .change_position(visual_x, self.text_fmt.tab_width); + word_width += grapheme.width(); + } + word_width + } + + fn advance_to_next_word(&mut self) { + self.word_buf.clear(); + let mut word_width = 0; + let virtual_lines_before_word = self.virtual_lines; + let mut virtual_lines_before_grapheme = self.virtual_lines; + + loop { + // softwrap word if necessary + if word_width + self.visual_pos.col >= self.text_fmt.viewport_width as usize { + // wrapping this word would move too much text to the next line + // split the word at the line end instead + if word_width > self.text_fmt.max_wrap as usize { + // Usually we stop accomulating graphemes as soon as softwrapping becomes necessary. + // However if the last grapheme is multiple columns wide it might extend beyond the EOL. + // The condition below ensures that this grapheme is not cutoff and instead wrapped to the next line + if word_width + self.visual_pos.col > self.text_fmt.viewport_width as usize { + self.peeked_grapheme = self.word_buf.pop().map(|grapheme| { + (grapheme, self.virtual_lines - virtual_lines_before_grapheme) + }); + self.virtual_lines = virtual_lines_before_grapheme; + } + return; + } + + word_width = self.wrap_word(virtual_lines_before_word); + } + + virtual_lines_before_grapheme = self.virtual_lines; + + let grapheme = if let Some((grapheme, virtual_lines)) = self.peeked_grapheme.take() { + self.virtual_lines += virtual_lines; + grapheme + } else if let Some(grapheme) = self.advance_grapheme(self.visual_pos.col + word_width) { + grapheme + } else { + return; + }; + + // Track indentation + if !grapheme.is_whitespace() && self.indent_level.is_none() { + self.indent_level = Some(self.visual_pos.col); + } else if grapheme.grapheme == Grapheme::Newline { + self.indent_level = None; + } + + let is_word_boundary = grapheme.is_word_boundary(); + word_width += grapheme.width(); + self.word_buf.push(grapheme); + + if is_word_boundary { + return; + } + } + } + + /// returns the document line pos of the **next** grapheme that will be yielded + pub fn line_pos(&self) -> usize { + self.line_pos + } + + /// returns the visual pos of the **next** grapheme that will be yielded + pub fn visual_pos(&self) -> Position { + self.visual_pos + } +} + +impl<'t> Iterator for DocumentFormatter<'t> { + type Item = (FormattedGrapheme<'t>, Position); + + fn next(&mut self) -> Option { + let grapheme = if self.text_fmt.soft_wrap { + if self.word_i >= self.word_buf.len() { + self.advance_to_next_word(); + self.word_i = 0; + } + let grapheme = replace( + self.word_buf.get_mut(self.word_i)?, + FormattedGrapheme::placeholder(), + ); + self.word_i += 1; + grapheme + } else { + self.advance_grapheme(self.visual_pos.col)? + }; + + let pos = self.visual_pos; + if grapheme.grapheme == Grapheme::Newline { + self.visual_pos.row += 1; + self.visual_pos.row += take(&mut self.virtual_lines); + self.visual_pos.col = 0; + self.line_pos += 1; + } else { + self.visual_pos.col += grapheme.width(); + } + Some((grapheme, pos)) + } +} diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs new file mode 100644 index 000000000000..e68b31fd570f --- /dev/null +++ b/helix-core/src/doc_formatter/test.rs @@ -0,0 +1,222 @@ +use std::rc::Rc; + +use crate::doc_formatter::{DocumentFormatter, TextFormat}; +use crate::text_annotations::{InlineAnnotation, Overlay, TextAnnotations}; + +impl TextFormat { + fn new_test(softwrap: bool) -> Self { + TextFormat { + soft_wrap: softwrap, + tab_width: 2, + max_wrap: 3, + max_indent_retain: 4, + wrap_indicator: ".".into(), + wrap_indicator_highlight: None, + // use a prime number to allow lining up too often with repeat + viewport_width: 17, + } + } +} + +impl<'t> DocumentFormatter<'t> { + fn collect_to_str(&mut self) -> String { + use std::fmt::Write; + let mut res = String::new(); + let viewport_width = self.text_fmt.viewport_width; + let mut line = 0; + + for (grapheme, pos) in self { + if pos.row != line { + line += 1; + assert_eq!(pos.row, line); + write!(res, "\n{}", ".".repeat(pos.col)).unwrap(); + assert!( + pos.col <= viewport_width as usize, + "softwrapped failed {}<={viewport_width}", + pos.col + ); + } + write!(res, "{}", grapheme.grapheme).unwrap(); + } + + res + } +} + +fn softwrap_text(text: &str) -> String { + DocumentFormatter::new_at_prev_checkpoint( + text.into(), + &TextFormat::new_test(true), + &TextAnnotations::default(), + 0, + ) + .0 + .collect_to_str() +} + +#[test] +fn basic_softwrap() { + assert_eq!( + softwrap_text(&"foo ".repeat(10)), + "foo foo foo foo \n.foo foo foo foo \n.foo foo " + ); + assert_eq!( + softwrap_text(&"fooo ".repeat(10)), + "fooo fooo fooo \n.fooo fooo fooo \n.fooo fooo fooo \n.fooo " + ); + + // check that we don't wrap unnecessarily + assert_eq!(softwrap_text("\t\txxxx1xxxx2xx\n"), " xxxx1xxxx2xx \n "); +} + +#[test] +fn softwrap_indentation() { + assert_eq!( + softwrap_text("\t\tfoo1 foo2 foo3 foo4 foo5 foo6\n"), + " foo1 foo2 \n.....foo3 foo4 \n.....foo5 foo6 \n " + ); + assert_eq!( + softwrap_text("\t\t\tfoo1 foo2 foo3 foo4 foo5 foo6\n"), + " foo1 foo2 \n.foo3 foo4 foo5 \n.foo6 \n " + ); +} + +#[test] +fn long_word_softwrap() { + assert_eq!( + softwrap_text("\t\txxxx1xxxx2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n"), + " xxxx1xxxx2xxx\n.....x3xxxx4xxxx5\n.....xxxx6xxxx7xx\n.....xx8xxxx9xxx \n " + ); + assert_eq!( + softwrap_text("xxxxxxxx1xxxx2xxx\n"), + "xxxxxxxx1xxxx2xxx\n. \n " + ); + assert_eq!( + softwrap_text("\t\txxxx1xxxx 2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n"), + " xxxx1xxxx \n.....2xxxx3xxxx4x\n.....xxx5xxxx6xxx\n.....x7xxxx8xxxx9\n.....xxx \n " + ); + assert_eq!( + softwrap_text("\t\txxxx1xxx 2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n"), + " xxxx1xxx 2xxx\n.....x3xxxx4xxxx5\n.....xxxx6xxxx7xx\n.....xx8xxxx9xxx \n " + ); +} + +fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay]) -> String { + DocumentFormatter::new_at_prev_checkpoint( + text.into(), + &TextFormat::new_test(softwrap), + TextAnnotations::default().add_overlay(overlays.into(), None), + char_pos, + ) + .0 + .collect_to_str() +} + +#[test] +fn overlay() { + assert_eq!( + overlay_text( + "foobar", + 0, + false, + &[ + Overlay { + char_idx: 0, + grapheme: "X".into(), + }, + Overlay { + char_idx: 2, + grapheme: "\t".into(), + }, + ] + ), + "Xo bar " + ); + assert_eq!( + overlay_text( + &"foo ".repeat(10), + 0, + true, + &[ + Overlay { + char_idx: 2, + grapheme: "\t".into(), + }, + Overlay { + char_idx: 5, + grapheme: "\t".into(), + }, + Overlay { + char_idx: 16, + grapheme: "X".into(), + }, + ] + ), + "fo f o foo \n.foo Xoo foo foo \n.foo foo foo " + ); +} + +fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) -> String { + DocumentFormatter::new_at_prev_checkpoint( + text.into(), + &TextFormat::new_test(softwrap), + TextAnnotations::default().add_inline_annotations(annotations.into(), None), + 0, + ) + .0 + .collect_to_str() +} + +#[test] +fn annotation() { + assert_eq!( + annotate_text( + "bar", + false, + &[InlineAnnotation { + char_idx: 0, + text: "foo".into(), + }] + ), + "foobar " + ); + assert_eq!( + annotate_text( + &"foo ".repeat(10), + true, + &[InlineAnnotation { + char_idx: 0, + text: "foo ".into(), + }] + ), + "foo foo foo foo \n.foo foo foo foo \n.foo foo foo " + ); +} +#[test] +fn annotation_and_overlay() { + assert_eq!( + DocumentFormatter::new_at_prev_checkpoint( + "bbar".into(), + &TextFormat::new_test(false), + TextAnnotations::default() + .add_inline_annotations( + Rc::new([InlineAnnotation { + char_idx: 0, + text: "fooo".into(), + }]), + None + ) + .add_overlay( + Rc::new([Overlay { + char_idx: 0, + grapheme: "\t".into(), + }]), + None + ), + 0, + ) + .0 + .collect_to_str(), + "fooo bar " + ); +} diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 675f57505c09..15ef3eb043e8 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -5,7 +5,88 @@ use ropey::{iter::Chunks, str_utils::byte_to_char_idx, RopeSlice}; use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete}; use unicode_width::UnicodeWidthStr; -use std::fmt; +use std::borrow::Cow; +use std::fmt::{self, Debug, Display}; +use std::marker::PhantomData; +use std::ops::Deref; +use std::ptr::NonNull; +use std::{slice, str}; + +use crate::chars::{char_is_whitespace, char_is_word}; +use crate::LineEnding; + +#[inline] +pub fn tab_width_at(visual_x: usize, tab_width: u16) -> usize { + tab_width as usize - (visual_x % tab_width as usize) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Grapheme<'a> { + Newline, + Tab { width: usize }, + Other { g: GraphemeStr<'a> }, +} + +impl<'a> Grapheme<'a> { + pub fn new(g: GraphemeStr<'a>, visual_x: usize, tab_width: u16) -> Grapheme<'a> { + match g { + g if g == "\t" => Grapheme::Tab { + width: tab_width_at(visual_x, tab_width), + }, + _ if LineEnding::from_str(&g).is_some() => Grapheme::Newline, + _ => Grapheme::Other { g }, + } + } + + pub fn change_position(&mut self, visual_x: usize, tab_width: u16) { + if let Grapheme::Tab { width } = self { + *width = tab_width_at(visual_x, tab_width) + } + } + + /// Returns the a visual width of this grapheme, + #[inline] + pub fn width(&self) -> usize { + match *self { + // width is not cached because we are dealing with + // ASCII almost all the time which already has a fastpath + // it's okay to convert to u16 here because no codepoint has a width larger + // than 2 and graphemes are usually atmost two visible codepoints wide + Grapheme::Other { ref g } => grapheme_width(g), + Grapheme::Tab { width } => width, + Grapheme::Newline => 1, + } + } + + pub fn is_whitespace(&self) -> bool { + !matches!(&self, Grapheme::Other { g } if !g.chars().all(char_is_whitespace)) + } + + // TODO currently word boundaries are used for softwrapping. + // This works best for programming languages and well for prose. + // This could however be improved in the future by considering unicode + // character classes but + pub fn is_word_boundary(&self) -> bool { + !matches!(&self, Grapheme::Other { g,.. } if g.chars().all(char_is_word)) + } +} + +impl Display for Grapheme<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Grapheme::Newline => write!(f, " "), + Grapheme::Tab { width } => { + for _ in 0..width { + write!(f, " ")?; + } + Ok(()) + } + Grapheme::Other { ref g } => { + write!(f, "{g}") + } + } + } +} #[must_use] pub fn grapheme_width(g: &str) -> usize { @@ -27,6 +108,8 @@ pub fn grapheme_width(g: &str) -> usize { // We use max(1) here because all grapeheme clusters--even illformed // ones--should have at least some width so they can be edited // properly. + // TODO properly handle unicode width for all codepoints + // example of where unicode width is currently wrong: 🤦🏼‍♂️ (taken from https://hsivonen.fi/string-length/) UnicodeWidthStr::width(g).max(1) } } @@ -341,3 +424,101 @@ impl<'a> Iterator for RopeGraphemes<'a> { } } } + +/// A highly compressed Cow<'a, str> that holds +/// atmost u31::MAX bytes and is readonly +pub struct GraphemeStr<'a> { + ptr: NonNull, + len: u32, + phantom: PhantomData<&'a str>, +} + +impl GraphemeStr<'_> { + const MASK_OWNED: u32 = 1 << 31; + + fn compute_len(&self) -> usize { + (self.len & !Self::MASK_OWNED) as usize + } +} + +impl Deref for GraphemeStr<'_> { + type Target = str; + fn deref(&self) -> &Self::Target { + unsafe { + let bytes = slice::from_raw_parts(self.ptr.as_ptr(), self.compute_len()); + str::from_utf8_unchecked(bytes) + } + } +} + +impl Drop for GraphemeStr<'_> { + fn drop(&mut self) { + if self.len & Self::MASK_OWNED != 0 { + // free allocation + unsafe { + drop(Box::from_raw(slice::from_raw_parts_mut( + self.ptr.as_ptr(), + self.compute_len(), + ))); + } + } + } +} + +impl<'a> From<&'a str> for GraphemeStr<'a> { + fn from(g: &'a str) -> Self { + GraphemeStr { + ptr: unsafe { NonNull::new_unchecked(g.as_bytes().as_ptr() as *mut u8) }, + len: i32::try_from(g.len()).unwrap() as u32, + phantom: PhantomData, + } + } +} + +impl<'a> From for GraphemeStr<'a> { + fn from(g: String) -> Self { + let len = g.len(); + let ptr = Box::into_raw(g.into_bytes().into_boxed_slice()) as *mut u8; + GraphemeStr { + ptr: unsafe { NonNull::new_unchecked(ptr) }, + len: i32::try_from(len).unwrap() as u32, + phantom: PhantomData, + } + } +} + +impl<'a> From> for GraphemeStr<'a> { + fn from(g: Cow<'a, str>) -> Self { + match g { + Cow::Borrowed(g) => g.into(), + Cow::Owned(g) => g.into(), + } + } +} + +impl> PartialEq for GraphemeStr<'_> { + fn eq(&self, other: &T) -> bool { + self.deref() == other.deref() + } +} +impl PartialEq for GraphemeStr<'_> { + fn eq(&self, other: &str) -> bool { + self.deref() == other + } +} +impl Eq for GraphemeStr<'_> {} +impl Debug for GraphemeStr<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Debug::fmt(self.deref(), f) + } +} +impl Display for GraphemeStr<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(self.deref(), f) + } +} +impl Clone for GraphemeStr<'_> { + fn clone(&self) -> Self { + self.deref().to_owned().into() + } +} diff --git a/helix-core/src/history.rs b/helix-core/src/history.rs index 51174c02475e..1aac38d934c7 100644 --- a/helix-core/src/history.rs +++ b/helix-core/src/history.rs @@ -54,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, @@ -119,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() { diff --git a/helix-core/src/increment/date_time.rs b/helix-core/src/increment/date_time.rs index 1574bf4d2e46..2980bb58b552 100644 --- a/helix-core/src/increment/date_time.rs +++ b/helix-core/src/increment/date_time.rs @@ -1,114 +1,53 @@ -use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; +use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; use once_cell::sync::Lazy; use regex::Regex; -use ropey::RopeSlice; - -use std::borrow::Cow; -use std::cmp; use std::fmt::Write; -use super::Increment; -use crate::{Range, Tendril}; +/// Increment a Date or DateTime +/// +/// If just a Date is selected the day will be incremented. +/// If a DateTime is selected the second will be incremented. +pub fn increment(selected_text: &str, amount: i64) -> Option { + if selected_text.is_empty() { + return None; + } -#[derive(Debug, PartialEq, Eq)] -pub struct DateTimeIncrementor { - date_time: NaiveDateTime, - range: Range, - fmt: &'static str, - field: DateField, -} + FORMATS.iter().find_map(|format| { + let captures = format.regex.captures(selected_text)?; + if captures.len() - 1 != format.fields.len() { + return None; + } -impl DateTimeIncrementor { - pub fn from_range(text: RopeSlice, range: Range) -> Option { - let range = if range.is_empty() { - if range.anchor < text.len_chars() { - // Treat empty range as a cursor range. - range.put_cursor(text, range.anchor + 1, true) - } else { - // The range is empty and at the end of the text. - return None; + let date_time = captures.get(0)?; + let has_date = format.fields.iter().any(|f| f.unit.is_date()); + let has_time = format.fields.iter().any(|f| f.unit.is_time()); + let date_time = &selected_text[date_time.start()..date_time.end()]; + match (has_date, has_time) { + (true, true) => { + let date_time = NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?; + Some( + date_time + .checked_add_signed(Duration::minutes(amount))? + .format(format.fmt) + .to_string(), + ) } - } else { - range - }; - - FORMATS.iter().find_map(|format| { - let from = range.from().saturating_sub(format.max_len); - let to = (range.from() + format.max_len).min(text.len_chars()); - - let (from_in_text, to_in_text) = (range.from() - from, range.to() - from); - let text: Cow = text.slice(from..to).into(); - - let captures = format.regex.captures(&text)?; - if captures.len() - 1 != format.fields.len() { - return None; + (true, false) => { + let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?; + Some( + date.checked_add_signed(Duration::days(amount))? + .format(format.fmt) + .to_string(), + ) } - - let date_time = captures.get(0)?; - let offset = range.from() - from_in_text; - let range = Range::new(date_time.start() + offset, date_time.end() + offset); - - let field = captures - .iter() - .skip(1) - .enumerate() - .find_map(|(i, capture)| { - let capture = capture?; - let capture_range = capture.range(); - - if capture_range.contains(&from_in_text) - && capture_range.contains(&(to_in_text - 1)) - { - Some(format.fields[i]) - } else { - None - } - })?; - - let has_date = format.fields.iter().any(|f| f.unit.is_date()); - let has_time = format.fields.iter().any(|f| f.unit.is_time()); - - let date_time = &text[date_time.start()..date_time.end()]; - let date_time = match (has_date, has_time) { - (true, true) => NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?, - (true, false) => { - let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?; - - date.and_hms(0, 0, 0) - } - (false, true) => { - let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?; - - NaiveDate::from_ymd(0, 1, 1).and_time(time) - } - (false, false) => return None, - }; - - Some(DateTimeIncrementor { - date_time, - range, - fmt: format.fmt, - field, - }) - }) - } -} - -impl Increment for DateTimeIncrementor { - fn increment(&self, amount: i64) -> (Range, Tendril) { - let date_time = match self.field.unit { - DateUnit::Years => add_years(self.date_time, amount), - DateUnit::Months => add_months(self.date_time, amount), - DateUnit::Days => add_duration(self.date_time, Duration::days(amount)), - DateUnit::Hours => add_duration(self.date_time, Duration::hours(amount)), - DateUnit::Minutes => add_duration(self.date_time, Duration::minutes(amount)), - DateUnit::Seconds => add_duration(self.date_time, Duration::seconds(amount)), - DateUnit::AmPm => toggle_am_pm(self.date_time), + (false, true) => { + let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?; + let (adjusted_time, _) = time.overflowing_add_signed(Duration::minutes(amount)); + Some(adjusted_time.format(format.fmt).to_string()) + } + (false, false) => None, } - .unwrap_or(self.date_time); - - (self.range, date_time.format(self.fmt).to_string().into()) - } + }) } static FORMATS: Lazy> = Lazy::new(|| { @@ -144,7 +83,7 @@ impl Format { fn new(fmt: &'static str) -> Self { let mut remaining = fmt; let mut fields = Vec::new(); - let mut regex = String::new(); + let mut regex = "^".to_string(); let mut max_len = 0; while let Some(i) = remaining.find('%') { @@ -166,6 +105,7 @@ impl Format { write!(regex, "({})", field.regex).unwrap(); remaining = &after[spec_len..]; } + regex += "$"; let regex = Regex::new(®ex).unwrap(); @@ -305,155 +245,47 @@ impl DateUnit { } } -fn ndays_in_month(year: i32, month: u32) -> u32 { - // The first day of the next month... - let (y, m) = if month == 12 { - (year + 1, 1) - } else { - (year, month + 1) - }; - let d = NaiveDate::from_ymd(y, m, 1); - - // ...is preceded by the last day of the original month. - d.pred().day() -} - -fn add_months(date_time: NaiveDateTime, amount: i64) -> Option { - let month = (date_time.month0() as i64).checked_add(amount)?; - let year = date_time.year() + i32::try_from(month / 12).ok()?; - let year = if month.is_negative() { year - 1 } else { year }; - - // Normalize month - let month = month % 12; - let month = if month.is_negative() { - month + 12 - } else { - month - } as u32 - + 1; - - let day = cmp::min(date_time.day(), ndays_in_month(year, month)); - - Some(NaiveDate::from_ymd(year, month, day).and_time(date_time.time())) -} - -fn add_years(date_time: NaiveDateTime, amount: i64) -> Option { - let year = i32::try_from((date_time.year() as i64).checked_add(amount)?).ok()?; - 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())) - } else { - date_time.with_year(year) - } -} - -fn add_duration(date_time: NaiveDateTime, duration: Duration) -> Option { - date_time.checked_add_signed(duration) -} - -fn toggle_am_pm(date_time: NaiveDateTime) -> Option { - if date_time.hour() < 12 { - add_duration(date_time, Duration::hours(12)) - } else { - add_duration(date_time, Duration::hours(-12)) - } -} - #[cfg(test)] mod test { use super::*; - use crate::Rope; #[test] fn test_increment_date_times() { let tests = [ // (original, cursor, amount, expected) - ("2020-02-28", 0, 1, "2021-02-28"), - ("2020-02-29", 0, 1, "2021-03-01"), - ("2020-01-31", 5, 1, "2020-02-29"), - ("2020-01-20", 5, 1, "2020-02-20"), - ("2021-01-01", 5, -1, "2020-12-01"), - ("2021-01-31", 5, -2, "2020-11-30"), - ("2020-02-28", 8, 1, "2020-02-29"), - ("2021-02-28", 8, 1, "2021-03-01"), - ("2021-02-28", 0, -1, "2020-02-28"), - ("2021-03-01", 0, -1, "2020-03-01"), - ("2020-02-29", 5, -1, "2020-01-29"), - ("2020-02-20", 5, -1, "2020-01-20"), - ("2020-02-29", 8, -1, "2020-02-28"), - ("2021-03-01", 8, -1, "2021-02-28"), - ("1980/12/21", 8, 100, "1981/03/31"), - ("1980/12/21", 8, -100, "1980/09/12"), - ("1980/12/21", 8, 1000, "1983/09/17"), - ("1980/12/21", 8, -1000, "1978/03/27"), - ("2021-11-24 07:12:23", 0, 1, "2022-11-24 07:12:23"), - ("2021-11-24 07:12:23", 5, 1, "2021-12-24 07:12:23"), - ("2021-11-24 07:12:23", 8, 1, "2021-11-25 07:12:23"), - ("2021-11-24 07:12:23", 11, 1, "2021-11-24 08:12:23"), - ("2021-11-24 07:12:23", 14, 1, "2021-11-24 07:13:23"), - ("2021-11-24 07:12:23", 17, 1, "2021-11-24 07:12:24"), - ("2021/11/24 07:12:23", 0, 1, "2022/11/24 07:12:23"), - ("2021/11/24 07:12:23", 5, 1, "2021/12/24 07:12:23"), - ("2021/11/24 07:12:23", 8, 1, "2021/11/25 07:12:23"), - ("2021/11/24 07:12:23", 11, 1, "2021/11/24 08:12:23"), - ("2021/11/24 07:12:23", 14, 1, "2021/11/24 07:13:23"), - ("2021/11/24 07:12:23", 17, 1, "2021/11/24 07:12:24"), - ("2021-11-24 07:12", 0, 1, "2022-11-24 07:12"), - ("2021-11-24 07:12", 5, 1, "2021-12-24 07:12"), - ("2021-11-24 07:12", 8, 1, "2021-11-25 07:12"), - ("2021-11-24 07:12", 11, 1, "2021-11-24 08:12"), - ("2021-11-24 07:12", 14, 1, "2021-11-24 07:13"), - ("2021/11/24 07:12", 0, 1, "2022/11/24 07:12"), - ("2021/11/24 07:12", 5, 1, "2021/12/24 07:12"), - ("2021/11/24 07:12", 8, 1, "2021/11/25 07:12"), - ("2021/11/24 07:12", 11, 1, "2021/11/24 08:12"), - ("2021/11/24 07:12", 14, 1, "2021/11/24 07:13"), - ("Wed Nov 24 2021", 0, 1, "Thu Nov 25 2021"), - ("Wed Nov 24 2021", 4, 1, "Fri Dec 24 2021"), - ("Wed Nov 24 2021", 8, 1, "Thu Nov 25 2021"), - ("Wed Nov 24 2021", 11, 1, "Thu Nov 24 2022"), - ("24-Nov-2021", 0, 1, "25-Nov-2021"), - ("24-Nov-2021", 3, 1, "24-Dec-2021"), - ("24-Nov-2021", 7, 1, "24-Nov-2022"), - ("2021 Nov 24", 0, 1, "2022 Nov 24"), - ("2021 Nov 24", 5, 1, "2021 Dec 24"), - ("2021 Nov 24", 9, 1, "2021 Nov 25"), - ("Nov 24, 2021", 0, 1, "Dec 24, 2021"), - ("Nov 24, 2021", 4, 1, "Nov 25, 2021"), - ("Nov 24, 2021", 8, 1, "Nov 24, 2022"), - ("7:21:53 am", 0, 1, "8:21:53 am"), - ("7:21:53 am", 3, 1, "7:22:53 am"), - ("7:21:53 am", 5, 1, "7:21:54 am"), - ("7:21:53 am", 8, 1, "7:21:53 pm"), - ("7:21:53 AM", 0, 1, "8:21:53 AM"), - ("7:21:53 AM", 3, 1, "7:22:53 AM"), - ("7:21:53 AM", 5, 1, "7:21:54 AM"), - ("7:21:53 AM", 8, 1, "7:21:53 PM"), - ("7:21 am", 0, 1, "8:21 am"), - ("7:21 am", 3, 1, "7:22 am"), - ("7:21 am", 5, 1, "7:21 pm"), - ("7:21 AM", 0, 1, "8:21 AM"), - ("7:21 AM", 3, 1, "7:22 AM"), - ("7:21 AM", 5, 1, "7:21 PM"), - ("23:24:23", 1, 1, "00:24:23"), - ("23:24:23", 3, 1, "23:25:23"), - ("23:24:23", 6, 1, "23:24:24"), - ("23:24", 1, 1, "00:24"), - ("23:24", 3, 1, "23:25"), + ("2020-02-28", 1, "2020-02-29"), + ("2020-02-29", 1, "2020-03-01"), + ("2020-01-31", 1, "2020-02-01"), + ("2020-01-20", 1, "2020-01-21"), + ("2021-01-01", -1, "2020-12-31"), + ("2021-01-31", -2, "2021-01-29"), + ("2020-02-28", 1, "2020-02-29"), + ("2021-02-28", 1, "2021-03-01"), + ("2021-03-01", -1, "2021-02-28"), + ("2020-02-29", -1, "2020-02-28"), + ("2020-02-20", -1, "2020-02-19"), + ("2021-03-01", -1, "2021-02-28"), + ("1980/12/21", 100, "1981/03/31"), + ("1980/12/21", -100, "1980/09/12"), + ("1980/12/21", 1000, "1983/09/17"), + ("1980/12/21", -1000, "1978/03/27"), + ("2021-11-24 07:12:23", 1, "2021-11-24 07:13:23"), + ("2021-11-24 07:12", 1, "2021-11-24 07:13"), + ("Wed Nov 24 2021", 1, "Thu Nov 25 2021"), + ("24-Nov-2021", 1, "25-Nov-2021"), + ("2021 Nov 24", 1, "2021 Nov 25"), + ("Nov 24, 2021", 1, "Nov 25, 2021"), + ("7:21:53 am", 1, "7:22:53 am"), + ("7:21:53 AM", 1, "7:22:53 AM"), + ("7:21 am", 1, "7:22 am"), + ("23:24:23", 1, "23:25:23"), + ("23:24", 1, "23:25"), + ("23:59", 1, "00:00"), + ("23:59:59", 1, "00:00:59"), ]; - for (original, cursor, amount, expected) in tests { - let rope = Rope::from_str(original); - let range = Range::new(cursor, cursor + 1); - assert_eq!( - DateTimeIncrementor::from_range(rope.slice(..), range) - .unwrap() - .increment(amount) - .1, - Tendril::from(expected) - ); + for (original, amount, expected) in tests { + assert_eq!(increment(original, amount).unwrap(), expected); } } @@ -482,10 +314,7 @@ mod test { ]; for invalid in tests { - let rope = Rope::from_str(invalid); - let range = Range::new(0, 1); - - assert_eq!(DateTimeIncrementor::from_range(rope.slice(..), range), None) + assert_eq!(increment(invalid, 1), None) } } } diff --git a/helix-core/src/increment/integer.rs b/helix-core/src/increment/integer.rs new file mode 100644 index 000000000000..30803e175b8c --- /dev/null +++ b/helix-core/src/increment/integer.rs @@ -0,0 +1,235 @@ +const SEPARATOR: char = '_'; + +/// Increment an integer. +/// +/// Supported bases: +/// 2 with prefix 0b +/// 8 with prefix 0o +/// 10 with no prefix +/// 16 with prefix 0x +/// +/// An integer can contain `_` as a separator but may not start or end with a separator. +/// Base 10 integers can go negative, but bases 2, 8, and 16 cannot. +/// All addition and subtraction is saturating. +pub fn increment(selected_text: &str, amount: i64) -> Option { + if selected_text.is_empty() + || selected_text.ends_with(SEPARATOR) + || selected_text.starts_with(SEPARATOR) + { + return None; + } + + let radix = if selected_text.starts_with("0x") { + 16 + } else if selected_text.starts_with("0o") { + 8 + } else if selected_text.starts_with("0b") { + 2 + } else { + 10 + }; + + // Get separator indexes from right to left. + let separator_rtl_indexes: Vec = selected_text + .chars() + .rev() + .enumerate() + .filter_map(|(i, c)| if c == SEPARATOR { Some(i) } else { None }) + .collect(); + + let word: String = selected_text.chars().filter(|&c| c != SEPARATOR).collect(); + + let mut new_text = if radix == 10 { + let number = &word; + let value = i128::from_str_radix(number, radix).ok()?; + let new_value = value.saturating_add(amount as i128); + + let format_length = match (value.is_negative(), new_value.is_negative()) { + (true, false) => number.len() - 1, + (false, true) => number.len() + 1, + _ => number.len(), + } - separator_rtl_indexes.len(); + + if number.starts_with('0') || number.starts_with("-0") { + format!("{:01$}", new_value, format_length) + } else { + format!("{}", new_value) + } + } else { + let number = &word[2..]; + let value = u128::from_str_radix(number, radix).ok()?; + let new_value = (value as i128).saturating_add(amount as i128); + let new_value = if new_value < 0 { 0 } else { new_value }; + let format_length = selected_text.len() - 2 - separator_rtl_indexes.len(); + + match radix { + 2 => format!("0b{:01$b}", new_value, format_length), + 8 => format!("0o{:01$o}", new_value, format_length), + 16 => { + let (lower_count, upper_count): (usize, usize) = + number.chars().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), + ) + }); + if upper_count > lower_count { + format!("0x{:01$X}", new_value, format_length) + } else { + format!("0x{:01$x}", new_value, format_length) + } + } + _ => unimplemented!("radix not supported: {}", radix), + } + }; + + // Add separators from original number. + for &rtl_index in &separator_rtl_indexes { + if rtl_index < new_text.len() { + let new_index = new_text.len().saturating_sub(rtl_index); + if new_index > 0 { + new_text.insert(new_index, SEPARATOR); + } + } + } + + // Add in additional separators if necessary. + if new_text.len() > selected_text.len() && !separator_rtl_indexes.is_empty() { + let spacing = match separator_rtl_indexes.as_slice() { + [.., b, a] => a - b - 1, + _ => separator_rtl_indexes[0], + }; + + let prefix_length = if radix == 10 { 0 } else { 2 }; + if let Some(mut index) = new_text.find(SEPARATOR) { + while index - prefix_length > spacing { + index -= spacing; + new_text.insert(index, SEPARATOR); + } + } + } + + Some(new_text) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_increment_basic_decimal_numbers() { + let tests = [ + ("100", 1, "101"), + ("100", -1, "99"), + ("99", 1, "100"), + ("100", 1000, "1100"), + ("100", -1000, "-900"), + ("-1", 1, "0"), + ("-1", 2, "1"), + ("1", -1, "0"), + ("1", -2, "-1"), + ]; + + for (original, amount, expected) in tests { + assert_eq!(increment(original, amount).unwrap(), expected); + } + } + + #[test] + fn test_increment_basic_hexadecimal_numbers() { + let tests = [ + ("0x0100", 1, "0x0101"), + ("0x0100", -1, "0x00ff"), + ("0x0001", -1, "0x0000"), + ("0x0000", -1, "0x0000"), + ("0xffffffffffffffff", 1, "0x10000000000000000"), + ("0xffffffffffffffff", 2, "0x10000000000000001"), + ("0xffffffffffffffff", -1, "0xfffffffffffffffe"), + ("0xABCDEF1234567890", 1, "0xABCDEF1234567891"), + ("0xabcdef1234567890", 1, "0xabcdef1234567891"), + ]; + + for (original, amount, expected) in tests { + assert_eq!(increment(original, amount).unwrap(), expected); + } + } + + #[test] + fn test_increment_basic_octal_numbers() { + let tests = [ + ("0o0107", 1, "0o0110"), + ("0o0110", -1, "0o0107"), + ("0o0001", -1, "0o0000"), + ("0o7777", 1, "0o10000"), + ("0o1000", -1, "0o0777"), + ("0o0107", 10, "0o0121"), + ("0o0000", -1, "0o0000"), + ("0o1777777777777777777777", 1, "0o2000000000000000000000"), + ("0o1777777777777777777777", 2, "0o2000000000000000000001"), + ("0o1777777777777777777777", -1, "0o1777777777777777777776"), + ]; + + for (original, amount, expected) in tests { + assert_eq!(increment(original, amount).unwrap(), expected); + } + } + + #[test] + fn test_increment_basic_binary_numbers() { + let tests = [ + ("0b00000100", 1, "0b00000101"), + ("0b00000100", -1, "0b00000011"), + ("0b00000100", 2, "0b00000110"), + ("0b00000100", -2, "0b00000010"), + ("0b00000001", -1, "0b00000000"), + ("0b00111111", 10, "0b01001001"), + ("0b11111111", 1, "0b100000000"), + ("0b10000000", -1, "0b01111111"), + ("0b0000", -1, "0b0000"), + ( + "0b1111111111111111111111111111111111111111111111111111111111111111", + 1, + "0b10000000000000000000000000000000000000000000000000000000000000000", + ), + ( + "0b1111111111111111111111111111111111111111111111111111111111111111", + 2, + "0b10000000000000000000000000000000000000000000000000000000000000001", + ), + ( + "0b1111111111111111111111111111111111111111111111111111111111111111", + -1, + "0b1111111111111111111111111111111111111111111111111111111111111110", + ), + ]; + + for (original, amount, expected) in tests { + assert_eq!(increment(original, amount).unwrap(), expected); + } + } + + #[test] + fn test_increment_with_separators() { + let tests = [ + ("999_999", 1, "1_000_000"), + ("1_000_000", -1, "999_999"), + ("-999_999", -1, "-1_000_000"), + ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), + ("0x0000_0000", -1, "0x0000_0000"), + ("0x0000_0000_0000", -1, "0x0000_0000_0000"), + ("0b01111111_11111111", 1, "0b10000000_00000000"), + ("0b11111111_11111111", 1, "0b1_00000000_00000000"), + ]; + + for (original, amount, expected) in tests { + assert_eq!(increment(original, amount).unwrap(), expected); + } + } + + #[test] + fn test_leading_and_trailing_separators_arent_a_match() { + assert_eq!(increment("9_", 1), None); + assert_eq!(increment("_9", 1), None); + assert_eq!(increment("_9_", 1), None); + } +} diff --git a/helix-core/src/increment/mod.rs b/helix-core/src/increment/mod.rs index f59457748e17..f1978bde469b 100644 --- a/helix-core/src/increment/mod.rs +++ b/helix-core/src/increment/mod.rs @@ -1,8 +1,10 @@ -pub mod date_time; -pub mod number; +mod date_time; +mod integer; -use crate::{Range, Tendril}; +pub fn integer(selected_text: &str, amount: i64) -> Option { + integer::increment(selected_text, amount) +} -pub trait Increment { - fn increment(&self, amount: i64) -> (Range, Tendril); +pub fn date_time(selected_text: &str, amount: i64) -> Option { + date_time::increment(selected_text, amount) } diff --git a/helix-core/src/increment/number.rs b/helix-core/src/increment/number.rs deleted file mode 100644 index 9126872937dc..000000000000 --- a/helix-core/src/increment/number.rs +++ /dev/null @@ -1,507 +0,0 @@ -use std::borrow::Cow; - -use ropey::RopeSlice; - -use super::Increment; - -use crate::{ - textobject::{textobject_word, TextObject}, - Range, Tendril, -}; - -#[derive(Debug, PartialEq, Eq)] -pub struct NumberIncrementor<'a> { - value: i64, - radix: u32, - range: Range, - - text: RopeSlice<'a>, -} - -impl<'a> NumberIncrementor<'a> { - /// Return information about number under rang if there is one. - pub fn from_range(text: RopeSlice, range: Range) -> Option { - // If the cursor is on the minus sign of a number we want to get the word textobject to the - // right of it. - let range = if range.to() < text.len_chars() - && range.to() - range.from() <= 1 - && text.char(range.from()) == '-' - { - Range::new(range.from() + 1, range.to() + 1) - } else { - range - }; - - let range = textobject_word(text, range, TextObject::Inside, 1, false); - - // If there is a minus sign to the left of the word object, we want to include it in the range. - let range = if range.from() > 0 && text.char(range.from() - 1) == '-' { - range.extend(range.from() - 1, range.from()) - } else { - range - }; - - let word: String = text - .slice(range.from()..range.to()) - .chars() - .filter(|&c| c != '_') - .collect(); - let (radix, prefixed) = if word.starts_with("0x") { - (16, true) - } else if word.starts_with("0o") { - (8, true) - } else if word.starts_with("0b") { - (2, true) - } else { - (10, false) - }; - - let number = if prefixed { &word[2..] } else { &word }; - - let value = i128::from_str_radix(number, radix).ok()?; - if (value.is_positive() && value.leading_zeros() < 64) - || (value.is_negative() && value.leading_ones() < 64) - { - return None; - } - - let value = value as i64; - Some(NumberIncrementor { - range, - value, - radix, - text, - }) - } -} - -impl<'a> Increment for NumberIncrementor<'a> { - fn increment(&self, amount: i64) -> (Range, Tendril) { - let old_text: Cow = self.text.slice(self.range.from()..self.range.to()).into(); - let old_length = old_text.len(); - let new_value = self.value.wrapping_add(amount); - - // Get separator indexes from right to left. - let separator_rtl_indexes: Vec = old_text - .chars() - .rev() - .enumerate() - .filter_map(|(i, c)| if c == '_' { Some(i) } else { None }) - .collect(); - - let format_length = if self.radix == 10 { - match (self.value.is_negative(), new_value.is_negative()) { - (true, false) => old_length - 1, - (false, true) => old_length + 1, - _ => old_text.len(), - } - } else { - old_text.len() - 2 - } - separator_rtl_indexes.len(); - - let mut new_text = match self.radix { - 2 => format!("0b{:01$b}", new_value, format_length), - 8 => format!("0o{:01$o}", new_value, format_length), - 10 if old_text.starts_with('0') || old_text.starts_with("-0") => { - format!("{:01$}", new_value, format_length) - } - 10 => format!("{}", new_value), - 16 => { - let (lower_count, upper_count): (usize, usize) = - old_text.chars().skip(2).fold((0, 0), |(lower, upper), c| { - ( - lower + usize::from(c.is_ascii_lowercase()), - upper + usize::from(c.is_ascii_uppercase()), - ) - }); - if upper_count > lower_count { - format!("0x{:01$X}", new_value, format_length) - } else { - format!("0x{:01$x}", new_value, format_length) - } - } - _ => unimplemented!("radix not supported: {}", self.radix), - }; - - // Add separators from original number. - for &rtl_index in &separator_rtl_indexes { - if rtl_index < new_text.len() { - let new_index = new_text.len() - rtl_index; - new_text.insert(new_index, '_'); - } - } - - // Add in additional separators if necessary. - if new_text.len() > old_length && !separator_rtl_indexes.is_empty() { - let spacing = match separator_rtl_indexes.as_slice() { - [.., b, a] => a - b - 1, - _ => separator_rtl_indexes[0], - }; - - let prefix_length = if self.radix == 10 { 0 } else { 2 }; - if let Some(mut index) = new_text.find('_') { - while index - prefix_length > spacing { - index -= spacing; - new_text.insert(index, '_'); - } - } - } - - (self.range, new_text.into()) - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::Rope; - - #[test] - fn test_decimal_at_point() { - let rope = Rope::from_str("Test text 12345 more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 15), - value: 12345, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_uppercase_hexadecimal_at_point() { - let rope = Rope::from_str("Test text 0x123ABCDEF more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 21), - value: 0x123ABCDEF, - radix: 16, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_lowercase_hexadecimal_at_point() { - let rope = Rope::from_str("Test text 0xfa3b4e more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 18), - value: 0xfa3b4e, - radix: 16, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_octal_at_point() { - let rope = Rope::from_str("Test text 0o1074312 more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 19), - value: 0o1074312, - radix: 8, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_binary_at_point() { - let rope = Rope::from_str("Test text 0b10111010010101 more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 26), - value: 0b10111010010101, - radix: 2, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_negative_decimal_at_point() { - let rope = Rope::from_str("Test text -54321 more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 16), - value: -54321, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_decimal_with_leading_zeroes_at_point() { - let rope = Rope::from_str("Test text 000045326 more text."); - let range = Range::point(12); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 19), - value: 45326, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_negative_decimal_cursor_on_minus_sign() { - let rope = Rope::from_str("Test text -54321 more text."); - let range = Range::point(10); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(10, 16), - value: -54321, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_number_under_range_start_of_rope() { - let rope = Rope::from_str("100"); - let range = Range::point(0); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(0, 3), - value: 100, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_number_under_range_end_of_rope() { - let rope = Rope::from_str("100"); - let range = Range::point(2); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(0, 3), - value: 100, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_number_surrounded_by_punctuation() { - let rope = Rope::from_str(",100;"); - let range = Range::point(1); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range), - Some(NumberIncrementor { - range: Range::new(1, 4), - value: 100, - radix: 10, - text: rope.slice(..), - }) - ); - } - - #[test] - fn test_not_a_number_point() { - let rope = Rope::from_str("Test text 45326 more text."); - let range = Range::point(6); - assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); - } - - #[test] - fn test_number_too_large_at_point() { - let rope = Rope::from_str("Test text 0xFFFFFFFFFFFFFFFFF more text."); - let range = Range::point(12); - assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); - } - - #[test] - fn test_number_cursor_one_right_of_number() { - let rope = Rope::from_str("100 "); - let range = Range::point(3); - assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); - } - - #[test] - fn test_number_cursor_one_left_of_number() { - let rope = Rope::from_str(" 100"); - let range = Range::point(0); - assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); - } - - #[test] - fn test_increment_basic_decimal_numbers() { - let tests = [ - ("100", 1, "101"), - ("100", -1, "99"), - ("99", 1, "100"), - ("100", 1000, "1100"), - ("100", -1000, "-900"), - ("-1", 1, "0"), - ("-1", 2, "1"), - ("1", -1, "0"), - ("1", -2, "-1"), - ]; - - for (original, amount, expected) in tests { - let rope = Rope::from_str(original); - let range = Range::point(0); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range) - .unwrap() - .increment(amount) - .1, - Tendril::from(expected) - ); - } - } - - #[test] - fn test_increment_basic_hexadecimal_numbers() { - let tests = [ - ("0x0100", 1, "0x0101"), - ("0x0100", -1, "0x00ff"), - ("0x0001", -1, "0x0000"), - ("0x0000", -1, "0xffffffffffffffff"), - ("0xffffffffffffffff", 1, "0x0000000000000000"), - ("0xffffffffffffffff", 2, "0x0000000000000001"), - ("0xffffffffffffffff", -1, "0xfffffffffffffffe"), - ("0xABCDEF1234567890", 1, "0xABCDEF1234567891"), - ("0xabcdef1234567890", 1, "0xabcdef1234567891"), - ]; - - for (original, amount, expected) in tests { - let rope = Rope::from_str(original); - let range = Range::point(0); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range) - .unwrap() - .increment(amount) - .1, - Tendril::from(expected) - ); - } - } - - #[test] - fn test_increment_basic_octal_numbers() { - let tests = [ - ("0o0107", 1, "0o0110"), - ("0o0110", -1, "0o0107"), - ("0o0001", -1, "0o0000"), - ("0o7777", 1, "0o10000"), - ("0o1000", -1, "0o0777"), - ("0o0107", 10, "0o0121"), - ("0o0000", -1, "0o1777777777777777777777"), - ("0o1777777777777777777777", 1, "0o0000000000000000000000"), - ("0o1777777777777777777777", 2, "0o0000000000000000000001"), - ("0o1777777777777777777777", -1, "0o1777777777777777777776"), - ]; - - for (original, amount, expected) in tests { - let rope = Rope::from_str(original); - let range = Range::point(0); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range) - .unwrap() - .increment(amount) - .1, - Tendril::from(expected) - ); - } - } - - #[test] - fn test_increment_basic_binary_numbers() { - let tests = [ - ("0b00000100", 1, "0b00000101"), - ("0b00000100", -1, "0b00000011"), - ("0b00000100", 2, "0b00000110"), - ("0b00000100", -2, "0b00000010"), - ("0b00000001", -1, "0b00000000"), - ("0b00111111", 10, "0b01001001"), - ("0b11111111", 1, "0b100000000"), - ("0b10000000", -1, "0b01111111"), - ( - "0b0000", - -1, - "0b1111111111111111111111111111111111111111111111111111111111111111", - ), - ( - "0b1111111111111111111111111111111111111111111111111111111111111111", - 1, - "0b0000000000000000000000000000000000000000000000000000000000000000", - ), - ( - "0b1111111111111111111111111111111111111111111111111111111111111111", - 2, - "0b0000000000000000000000000000000000000000000000000000000000000001", - ), - ( - "0b1111111111111111111111111111111111111111111111111111111111111111", - -1, - "0b1111111111111111111111111111111111111111111111111111111111111110", - ), - ]; - - for (original, amount, expected) in tests { - let rope = Rope::from_str(original); - let range = Range::point(0); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range) - .unwrap() - .increment(amount) - .1, - Tendril::from(expected) - ); - } - } - - #[test] - fn test_increment_with_separators() { - let tests = [ - ("999_999", 1, "1_000_000"), - ("1_000_000", -1, "999_999"), - ("-999_999", -1, "-1_000_000"), - ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), - ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), - ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), - ("0x0000_0000", -1, "0xffff_ffff_ffff_ffff"), - ("0x0000_0000_0000", -1, "0xffff_ffff_ffff_ffff"), - ("0b01111111_11111111", 1, "0b10000000_00000000"), - ("0b11111111_11111111", 1, "0b1_00000000_00000000"), - ]; - - for (original, amount, expected) in tests { - let rope = Rope::from_str(original); - let range = Range::point(0); - assert_eq!( - NumberIncrementor::from_range(rope.slice(..), range) - .unwrap() - .increment(amount) - .1, - Tendril::from(expected) - ); - } - } -} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 5f60c04890de..e3f862a6054c 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod comment; pub mod config; pub mod diagnostic; pub mod diff; +pub mod doc_formatter; pub mod graphemes; pub mod history; pub mod increment; @@ -24,6 +25,7 @@ pub mod shellwords; pub mod surround; pub mod syntax; pub mod test; +pub mod text_annotations; pub mod textobject; mod transaction; pub mod wrap; @@ -69,7 +71,7 @@ pub fn find_root(root: Option<&str>, root_markers: &[String]) -> std::path::Path top_marker = Some(ancestor); } - if ancestor.join(".git").is_dir() { + 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); @@ -83,7 +85,7 @@ pub fn find_root(root: Option<&str>, root_markers: &[String]) -> std::path::Path 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; @@ -95,8 +97,12 @@ pub use {regex, tree_sitter}; pub use graphemes::RopeGraphemes; pub use position::{ - coords_at_pos, pos_at_coords, pos_at_visual_coords, visual_coords_at_pos, Position, + char_idx_at_visual_offset, coords_at_pos, pos_at_coords, visual_offset_from_anchor, + visual_offset_from_block, Position, }; +#[allow(deprecated)] +pub use position::{pos_at_visual_coords, visual_coords_at_pos}; + pub use selection::{Range, Selection}; pub use smallvec::{smallvec, SmallVec}; pub use syntax::Syntax; diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 278375e8f471..11c12a6f0b25 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -4,16 +4,19 @@ use ropey::iter::Chars; use tree_sitter::{Node, QueryCursor}; use crate::{ + char_idx_at_visual_offset, chars::{categorize_char, char_is_line_ending, CharCategory}, + doc_formatter::TextFormat, graphemes::{ next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary, prev_grapheme_boundary, }, line_ending::rope_is_line_ending, - pos_at_visual_coords, + position::char_idx_at_visual_block_offset, syntax::LanguageConfiguration, + text_annotations::TextAnnotations, textobject::TextObject, - visual_coords_at_pos, Position, Range, RopeSlice, + visual_offset_from_block, Range, RopeSlice, }; #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -34,7 +37,8 @@ pub fn move_horizontally( dir: Direction, count: usize, behaviour: Movement, - _: usize, + _: &TextFormat, + _: &mut TextAnnotations, ) -> Range { let pos = range.cursor(slice); @@ -48,35 +52,116 @@ pub fn move_horizontally( range.put_cursor(slice, new_pos, behaviour == Movement::Extend) } +pub fn move_vertically_visual( + slice: RopeSlice, + range: Range, + dir: Direction, + count: usize, + behaviour: Movement, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, +) -> Range { + if !text_fmt.soft_wrap { + move_vertically(slice, range, dir, count, behaviour, text_fmt, annotations); + } + annotations.clear_line_annotations(); + let pos = range.cursor(slice); + + // Compute the current position's 2d coordinates. + let (visual_pos, block_off) = visual_offset_from_block(slice, pos, pos, text_fmt, annotations); + let new_col = range + .old_visual_position + .map_or(visual_pos.col as u32, |(_, col)| col); + + // Compute the new position. + let mut row_off = match dir { + Direction::Forward => count as isize, + Direction::Backward => -(count as isize), + }; + + // TODO how to handle inline annotations that span an entire visual line (very unlikely). + + // Compute visual offset relative to block start to avoid trasversing the block twice + row_off += visual_pos.row as isize; + let new_pos = char_idx_at_visual_offset( + slice, + block_off, + row_off, + new_col as usize, + text_fmt, + annotations, + ) + .0; + + // Special-case to avoid moving to the end of the last non-empty line. + if behaviour == Movement::Extend && slice.line(slice.char_to_line(new_pos)).len_chars() == 0 { + return range; + } + + let mut new_range = range.put_cursor(slice, new_pos, behaviour == Movement::Extend); + new_range.old_visual_position = Some((0, new_col)); + new_range +} + pub fn move_vertically( slice: RopeSlice, range: Range, dir: Direction, count: usize, behaviour: Movement, - tab_width: usize, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, ) -> Range { + annotations.clear_line_annotations(); let pos = range.cursor(slice); + let line_idx = slice.char_to_line(pos); + let line_start = slice.line_to_char(line_idx); // Compute the current position's 2d coordinates. - let Position { row, col } = visual_coords_at_pos(slice, pos, tab_width); - let horiz = range.horiz.unwrap_or(col as u32); + let visual_pos = visual_offset_from_block(slice, line_start, pos, text_fmt, annotations).0; + let (mut new_row, new_col) = range + .old_visual_position + .map_or((visual_pos.row as u32, visual_pos.col as u32), |pos| pos); + new_row = new_row.max(visual_pos.row as u32); + let line_idx = slice.char_to_line(pos); // Compute the new position. - let new_row = match dir { - Direction::Forward => (row + count).min(slice.len_lines().saturating_sub(1)), - Direction::Backward => row.saturating_sub(count), + let mut new_line_idx = match dir { + Direction::Forward => line_idx.saturating_add(count), + Direction::Backward => line_idx.saturating_sub(count), }; - let new_col = col.max(horiz as usize); - let new_pos = pos_at_visual_coords(slice, Position::new(new_row, new_col), tab_width); + + let line = if new_line_idx >= slice.len_lines() - 1 { + // there is no line terminator for the last line + // so the logic below is not necessary here + new_line_idx = slice.len_lines() - 1; + slice + } else { + // char_idx_at_visual_block_offset returns a one-past-the-end index + // in case it reaches the end of the slice + // to avoid moving to the nextline in that case the line terminator is removed from the line + let new_line_end = prev_grapheme_boundary(slice, slice.line_to_char(new_line_idx + 1)); + slice.slice(..new_line_end) + }; + + let new_line_start = line.line_to_char(new_line_idx); + + let (new_pos, _) = char_idx_at_visual_block_offset( + line, + new_line_start, + new_row as usize, + new_col as usize, + text_fmt, + annotations, + ); // Special-case to avoid moving to the end of the last non-empty line. - if behaviour == Movement::Extend && slice.line(new_row).len_chars() == 0 { + if behaviour == Movement::Extend && slice.line(new_line_idx).len_chars() == 0 { return range; } let mut new_range = range.put_cursor(slice, new_pos, behaviour == Movement::Extend); - new_range.horiz = Some(horiz); + new_range.old_visual_position = Some((new_row, new_col)); new_range } @@ -473,7 +558,16 @@ mod test { assert_eq!( coords_at_pos( slice, - move_vertically(slice, range, Direction::Forward, 1, Movement::Move, 4).head + move_vertically_visual( + slice, + range, + Direction::Forward, + 1, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ) + .head ), (1, 3).into() ); @@ -497,7 +591,15 @@ mod test { ]; for ((direction, amount), coordinates) in moves_and_expected_coordinates { - range = move_horizontally(slice, range, direction, amount, Movement::Move, 0); + range = move_horizontally( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ); assert_eq!(coords_at_pos(slice, range.head), coordinates.into()) } } @@ -523,7 +625,15 @@ mod test { ]; for ((direction, amount), coordinates) in moves_and_expected_coordinates { - range = move_horizontally(slice, range, direction, amount, Movement::Move, 0); + range = move_horizontally( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ); assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(range.head, range.anchor); } @@ -545,7 +655,15 @@ mod test { ]; for (direction, amount) in moves { - range = move_horizontally(slice, range, direction, amount, Movement::Extend, 0); + range = move_horizontally( + slice, + range, + direction, + amount, + Movement::Extend, + &TextFormat::default(), + &mut TextAnnotations::default(), + ); assert_eq!(range.anchor, original_anchor); } } @@ -569,7 +687,15 @@ mod test { ]; for ((direction, amount), coordinates) in moves_and_expected_coordinates { - range = move_vertically(slice, range, direction, amount, Movement::Move, 4); + range = move_vertically_visual( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ); assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(range.head, range.anchor); } @@ -603,8 +729,24 @@ mod test { for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { range = match axis { - Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move, 0), - Axis::V => move_vertically(slice, range, direction, amount, Movement::Move, 4), + Axis::H => move_horizontally( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ), + Axis::V => move_vertically_visual( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ), }; assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(range.head, range.anchor); @@ -638,8 +780,24 @@ mod test { for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { range = match axis { - Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move, 0), - Axis::V => move_vertically(slice, range, direction, amount, Movement::Move, 4), + Axis::H => move_horizontally( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ), + Axis::V => move_vertically_visual( + slice, + range, + direction, + amount, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ), }; assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(range.head, range.anchor); diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index f456eb988186..7b8dc326e82c 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -1,9 +1,11 @@ -use std::borrow::Cow; +use std::{borrow::Cow, cmp::Ordering}; use crate::{ chars::char_is_line_ending, + doc_formatter::{DocumentFormatter, TextFormat}, graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes}, line_ending::line_end_char_index, + text_annotations::TextAnnotations, RopeSlice, }; @@ -73,6 +75,13 @@ pub fn coords_at_pos(text: RopeSlice, pos: usize) -> Position { /// Takes \t, double-width characters (CJK) into account as well as text /// not in the document in the future. /// See [`coords_at_pos`] for an "objective" one. +/// +/// This function should be used very rarely. Usually `visual_offset_from_anchor` +/// or `visual_offset_from_block` is preferable. However when you want to compute the +/// actual visual row/column in the text (not what is actually shown on screen) +/// then you should use this function. For example aligning text should ignore virtual +/// text and softwrap. +#[deprecated = "Doesn't account for softwrap or decorations, use visual_offset_from_anchor instead"] pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Position { let line = text.char_to_line(pos); @@ -93,6 +102,82 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po Position::new(line, col) } +/// Returns the visual offset from the start of the first visual line +/// in the block that contains anchor. +/// Text is always wrapped at blocks, they usually correspond to +/// actual line breaks but for very long lines +/// softwrapping positions are estimated with an O(1) algorithm +/// to ensure consistent performance for large lines (currently unimplemented) +/// +/// Usualy you want to use `visual_offset_from_anchor` instead but this function +/// can be useful (and faster) if +/// * You already know the visual position of the block +/// * You only care about the horizontal offset (column) and not the vertical offset (row) +pub fn visual_offset_from_block( + text: RopeSlice, + anchor: usize, + pos: usize, + text_fmt: &TextFormat, + annotations: &TextAnnotations, +) -> (Position, usize) { + let mut last_pos = Position::default(); + let (formatter, block_start) = + DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); + let mut char_pos = block_start; + + for (grapheme, vpos) in formatter { + last_pos = vpos; + char_pos += grapheme.doc_chars(); + + if char_pos > pos { + return (last_pos, block_start); + } + } + + (last_pos, block_start) +} + +/// Returns the visual offset from the start of the visual line +/// that contains anchor. +pub fn visual_offset_from_anchor( + text: RopeSlice, + anchor: usize, + pos: usize, + text_fmt: &TextFormat, + annotations: &TextAnnotations, + max_rows: usize, +) -> Option<(Position, usize)> { + let (formatter, block_start) = + DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); + let mut char_pos = block_start; + let mut anchor_line = None; + let mut last_pos = Position::default(); + + for (grapheme, vpos) in formatter { + last_pos = vpos; + char_pos += grapheme.doc_chars(); + + if char_pos > anchor && anchor_line.is_none() { + anchor_line = Some(last_pos.row); + } + if char_pos > pos { + last_pos.row -= anchor_line.unwrap(); + return Some((last_pos, block_start)); + } + + if let Some(anchor_line) = anchor_line { + if vpos.row >= anchor_line + max_rows { + return None; + } + } + } + + let anchor_line = anchor_line.unwrap_or(last_pos.row); + last_pos.row -= anchor_line; + + Some((last_pos, block_start)) +} + /// Convert (line, column) coordinates to a character index. /// /// If the `line` coordinate is beyond the end of the file, the EOF @@ -140,6 +225,11 @@ pub fn pos_at_coords(text: RopeSlice, coords: Position, limit_before_line_ending /// If the `column` coordinate is past the end of the given line, the /// line-end position (in this case, just before the line ending /// character) will be returned. +/// This function should be used very rarely. Usually `char_idx_at_visual_offset` is preferable. +/// However when you want to compute a char position from the visual row/column in the text +/// (not what is actually shown on screen) then you should use this function. +/// For example aligning text should ignore virtual text and softwrap. +#[deprecated = "Doesn't account for softwrap or decorations, use char_idx_at_visual_offset instead"] pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize) -> usize { let Position { mut row, col } = coords; row = row.min(text.len_lines() - 1); @@ -169,6 +259,120 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize) line_start + col_char_offset } +/// Returns the char index on the visual line `row_offset` below the visual line of +/// the provided char index `anchor` that is closest to the supplied visual `column`. +/// +/// If the targeted visual line is entirely covered by virtual text the last +/// char position before the virtual text and a virtual offset is returned instead. +/// +/// If no (text) grapheme starts at exactly at the specified column the +/// start of the grapheme to the left is returned. If there is no grapheme +/// to the left (for example if the line starts with virtual text) then the positiong +/// of the next grapheme to the right is returned. +/// +/// If the `line` coordinate is beyond the end of the file, the EOF +/// position will be returned. +/// +/// If the `column` coordinate is past the end of the given line, the +/// line-end position (in this case, just before the line ending +/// character) will be returned. +/// +/// # Returns +/// +/// `(real_char_idx, virtual_lines)` +/// +/// The nearest character idx "closest" (see above) to the specified visual offset +/// on the visual line is returned if the visual line contains any text: +/// If the visual line at the specified offset is a virtual line generated by a `LineAnnotation` +/// the previous char_index is returned, together with the remaining vertical offset (`virtual_lines`) +pub fn char_idx_at_visual_offset<'a>( + text: RopeSlice<'a>, + mut anchor: usize, + mut row_offset: isize, + column: usize, + text_fmt: &TextFormat, + annotations: &TextAnnotations, +) -> (usize, usize) { + // convert row relative to visual line containing anchor to row relative to a block containing anchor (anchor may change) + loop { + let (visual_pos_in_block, block_char_offset) = + visual_offset_from_block(text, anchor, anchor, text_fmt, annotations); + row_offset += visual_pos_in_block.row as isize; + anchor = block_char_offset; + if row_offset >= 0 { + break; + } + + if block_char_offset == 0 { + row_offset = 0; + break; + } + // the row_offset is negative so we need to look at the previous block + // set the anchor to the last char before the current block + // this char index is also always a line earlier so increase the row_offset by 1 + anchor -= 1; + row_offset += 1; + } + + char_idx_at_visual_block_offset( + text, + anchor, + row_offset as usize, + column, + text_fmt, + annotations, + ) +} + +/// This function behaves the same as `char_idx_at_visual_offset`, except that +/// the vertical offset `row` is always computed relative to the block that contains `anchor` +/// instead of the visual line that contains `anchor`. +/// Usually `char_idx_at_visual_offset` is more useful but this function can be +/// used in some situations as an optimization when `visual_offset_from_block` was used +/// +/// # Returns +/// +/// `(real_char_idx, virtual_lines)` +/// +/// See `char_idx_at_visual_offset` for details +pub fn char_idx_at_visual_block_offset( + text: RopeSlice, + anchor: usize, + row: usize, + column: usize, + text_fmt: &TextFormat, + annotations: &TextAnnotations, +) -> (usize, usize) { + let (formatter, mut char_idx) = + DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor); + let mut last_char_idx = char_idx; + let mut last_char_idx_on_line = None; + let mut last_row = 0; + for (grapheme, grapheme_pos) in formatter { + match grapheme_pos.row.cmp(&row) { + Ordering::Equal => { + if grapheme_pos.col + grapheme.width() > column { + if !grapheme.is_virtual() { + return (char_idx, 0); + } else if let Some(char_idx) = last_char_idx_on_line { + return (char_idx, 0); + } + } else if !grapheme.is_virtual() { + last_char_idx_on_line = Some(char_idx) + } + } + Ordering::Greater => return (last_char_idx, row - last_row), + _ => (), + } + + last_char_idx = char_idx; + last_row = grapheme_pos.row; + char_idx += grapheme.doc_chars(); + } + + (char_idx, 0) +} + #[cfg(test)] mod test { use super::*; @@ -228,6 +432,7 @@ mod test { } #[test] + #[allow(deprecated)] fn test_visual_coords_at_pos() { let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); let slice = text.slice(..); @@ -275,6 +480,130 @@ mod test { assert_eq!(visual_coords_at_pos(slice, 2, 8), (0, 9).into()); } + #[test] + fn test_visual_off_from_block() { + let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); + let slice = text.slice(..); + let annot = TextAnnotations::default(); + let text_fmt = TextFormat::default(); + assert_eq!( + visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0, + (0, 0).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 5, &text_fmt, &annot).0, + (0, 5).into() + ); // position on \n + assert_eq!( + visual_offset_from_block(slice, 0, 6, &text_fmt, &annot).0, + (1, 0).into() + ); // position on w + assert_eq!( + visual_offset_from_block(slice, 0, 7, &text_fmt, &annot).0, + (1, 1).into() + ); // position on o + assert_eq!( + visual_offset_from_block(slice, 0, 10, &text_fmt, &annot).0, + (1, 4).into() + ); // position on d + + // Test with wide characters. + let text = Rope::from("今日はいい\n"); + let slice = text.slice(..); + assert_eq!( + visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0, + (0, 0).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 1, &text_fmt, &annot).0, + (0, 2).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0, + (0, 4).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 3, &text_fmt, &annot).0, + (0, 6).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 4, &text_fmt, &annot).0, + (0, 8).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 5, &text_fmt, &annot).0, + (0, 10).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 6, &text_fmt, &annot).0, + (1, 0).into() + ); + + // Test with grapheme clusters. + let text = Rope::from("a̐éö̲\r\n"); + let slice = text.slice(..); + assert_eq!( + visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0, + (0, 0).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0, + (0, 1).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 4, &text_fmt, &annot).0, + (0, 2).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 7, &text_fmt, &annot).0, + (0, 3).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 9, &text_fmt, &annot).0, + (1, 0).into() + ); + + // Test with wide-character grapheme clusters. + // TODO: account for cluster. + let text = Rope::from("किमपि\n"); + let slice = text.slice(..); + assert_eq!( + visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0, + (0, 0).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0, + (0, 2).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 3, &text_fmt, &annot).0, + (0, 3).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 5, &text_fmt, &annot).0, + (0, 5).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 6, &text_fmt, &annot).0, + (1, 0).into() + ); + + // Test with tabs. + let text = Rope::from("\tHello\n"); + let slice = text.slice(..); + assert_eq!( + visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0, + (0, 0).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 1, &text_fmt, &annot).0, + (0, 4).into() + ); + assert_eq!( + visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0, + (0, 5).into() + ); + } #[test] fn test_pos_at_coords() { let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); @@ -341,6 +670,7 @@ mod test { } #[test] + #[allow(deprecated)] fn test_pos_at_visual_coords() { let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); let slice = text.slice(..); @@ -405,4 +735,100 @@ mod test { assert_eq!(pos_at_visual_coords(slice, (0, 10).into(), 4), 0); assert_eq!(pos_at_visual_coords(slice, (10, 10).into(), 4), 0); } + + #[test] + fn test_char_idx_at_visual_row_offset() { + let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ\nfoo"); + let slice = text.slice(..); + let mut text_fmt = TextFormat::default(); + for i in 0isize..3isize { + for j in -2isize..=2isize { + if !(0..3).contains(&(i + j)) { + continue; + } + println!("{i} {j}"); + assert_eq!( + char_idx_at_visual_offset( + slice, + slice.line_to_char(i as usize), + j, + 3, + &text_fmt, + &TextAnnotations::default(), + ) + .0, + slice.line_to_char((i + j) as usize) + 3 + ); + } + } + + text_fmt.soft_wrap = true; + let mut softwrapped_text = "foo ".repeat(10); + softwrapped_text.push('\n'); + let last_char = softwrapped_text.len() - 1; + + let text = Rope::from(softwrapped_text.repeat(3)); + let slice = text.slice(..); + assert_eq!( + char_idx_at_visual_offset( + slice, + last_char, + 0, + 0, + &text_fmt, + &TextAnnotations::default(), + ) + .0, + 32 + ); + assert_eq!( + char_idx_at_visual_offset( + slice, + last_char, + -1, + 0, + &text_fmt, + &TextAnnotations::default(), + ) + .0, + 16 + ); + assert_eq!( + char_idx_at_visual_offset( + slice, + last_char, + -2, + 0, + &text_fmt, + &TextAnnotations::default(), + ) + .0, + 0 + ); + assert_eq!( + char_idx_at_visual_offset( + slice, + softwrapped_text.len() + last_char, + -2, + 0, + &text_fmt, + &TextAnnotations::default(), + ) + .0, + softwrapped_text.len() + ); + + assert_eq!( + char_idx_at_visual_offset( + slice, + softwrapped_text.len() + last_char, + -5, + 0, + &text_fmt, + &TextAnnotations::default(), + ) + .0, + 0 + ); + } } diff --git a/helix-core/src/register.rs b/helix-core/src/register.rs index 1cff77ba3717..52eb6e3e72bf 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 1f28ecefb750..7817618fb488 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -53,7 +53,9 @@ pub struct Range { pub anchor: usize, /// The head of the range, moved when extending. pub head: usize, - pub horiz: Option, + /// The previous visual offset (softwrapped lines and columns) from + /// the start of the line + pub old_visual_position: Option<(u32, u32)>, } impl Range { @@ -61,7 +63,7 @@ impl Range { Self { anchor, head, - horiz: None, + old_visual_position: None, } } @@ -127,7 +129,7 @@ impl Range { Self { anchor: self.head, head: self.anchor, - horiz: self.horiz, + old_visual_position: self.old_visual_position, } } @@ -185,7 +187,7 @@ impl Range { Self { anchor, head, - horiz: None, + old_visual_position: None, } } @@ -198,13 +200,13 @@ impl Range { Self { anchor: self.anchor.min(from), head: self.head.max(to), - horiz: None, + old_visual_position: None, } } else { Self { anchor: self.anchor.max(to), head: self.head.min(from), - horiz: None, + old_visual_position: None, } } } @@ -219,13 +221,13 @@ impl Range { Range { anchor: self.anchor.max(other.anchor), head: self.head.min(other.head), - horiz: None, + old_visual_position: None, } } else { Range { anchor: self.from().min(other.from()), head: self.to().max(other.to()), - horiz: None, + old_visual_position: None, } } } @@ -279,8 +281,8 @@ impl Range { Range { anchor: new_anchor, head: new_head, - horiz: if new_anchor == self.anchor { - self.horiz + old_visual_position: if new_anchor == self.anchor { + self.old_visual_position } else { None }, @@ -306,7 +308,7 @@ impl Range { Range { anchor: self.anchor, head: next_grapheme_boundary(slice, self.head), - horiz: self.horiz, + old_visual_position: self.old_visual_position, } } else { *self @@ -378,7 +380,7 @@ impl From<(usize, usize)> for Range { Self { anchor, head, - horiz: None, + old_visual_position: None, } } } @@ -482,7 +484,7 @@ impl Selection { ranges: smallvec![Range { anchor, head, - horiz: None + old_visual_position: None }], primary_index: 0, } @@ -495,28 +497,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]; + false } - if i == self.primary_index { - self.primary_index = prev_i; - } - } + }); - self.ranges.truncate(prev_i + 1); + self.primary_index = self + .ranges + .iter() + .position(|&range| range == primary) + .unwrap(); self } @@ -541,9 +568,9 @@ impl Selection { } /// Takes a closure and maps each `Range` over the closure. - pub fn transform(mut self, f: F) -> Self + pub fn transform(mut self, mut f: F) -> Self where - F: Fn(Range) -> Range, + F: FnMut(Range) -> Range, { for range in self.ranges.iter_mut() { *range = f(*range) @@ -1132,6 +1159,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 7742896c2866..0883eb9172ae 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -27,181 +27,173 @@ enum State { DquoteEscaped, } -/// Get the vec of escaped / quoted / doublequoted filenames from the input str -pub fn shellwords(input: &str) -> Vec> { - use State::*; +pub struct Shellwords<'a> { + state: State, + /// Shellwords where whitespace and escapes has been resolved. + words: Vec>, + /// The parts of the input that are divided into shellwords. This can be + /// used to retrieve the original text for a given word by looking up the + /// same index in the Vec as the word in `words`. + parts: Vec<&'a str>, +} - let mut state = Unquoted; - let mut args: Vec> = Vec::new(); - let mut escaped = String::with_capacity(input.len()); +impl<'a> From<&'a str> for Shellwords<'a> { + fn from(input: &'a str) -> Self { + use State::*; - let mut start = 0; - let mut end = 0; + let mut state = Unquoted; + let mut words = Vec::new(); + let mut parts = Vec::new(); + let mut escaped = String::with_capacity(input.len()); - for (i, c) in input.char_indices() { - state = match state { - OnWhitespace => match c { - '"' => { - end = i; - Dquoted - } - '\'' => { - end = i; - Quoted - } - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - UnquotedEscaped - } else { + let mut part_start = 0; + let mut unescaped_start = 0; + let mut end = 0; + + for (i, c) in input.char_indices() { + state = match state { + OnWhitespace => match c { + '"' => { + end = i; + Dquoted + } + '\'' => { + end = i; + Quoted + } + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + OnWhitespace + } + } + c if c.is_ascii_whitespace() => { + end = i; OnWhitespace } - } - c if c.is_ascii_whitespace() => { - end = i; - OnWhitespace - } - _ => Unquoted, - }, - Unquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - UnquotedEscaped - } else { - Unquoted + _ => Unquoted, + }, + Unquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + Unquoted + } } - } - c if c.is_ascii_whitespace() => { - end = i; - OnWhitespace - } - _ => Unquoted, - }, - UnquotedEscaped => Unquoted, - Quoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - QuoteEscaped - } else { - Quoted + c if c.is_ascii_whitespace() => { + end = i; + OnWhitespace } - } - '\'' => { - end = i; - OnWhitespace - } - _ => Quoted, - }, - QuoteEscaped => Quoted, - Dquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[start..i]); - start = i + 1; - DquoteEscaped - } else { - Dquoted + _ => Unquoted, + }, + UnquotedEscaped => Unquoted, + Quoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + QuoteEscaped + } else { + Quoted + } } - } - '"' => { - end = i; - OnWhitespace - } - _ => Dquoted, - }, - DquoteEscaped => Dquoted, - }; + '\'' => { + end = i; + OnWhitespace + } + _ => Quoted, + }, + QuoteEscaped => Quoted, + Dquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + DquoteEscaped + } else { + Dquoted + } + } + '"' => { + end = i; + OnWhitespace + } + _ => Dquoted, + }, + DquoteEscaped => Dquoted, + }; - if i >= input.len() - 1 && end == 0 { - end = i + 1; - } + let c_len = c.len_utf8(); + if i == input.len() - c_len && end == 0 { + end = i + c_len; + } - 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; } - } - args -} -/// Checks that the input ends with an ascii whitespace character which is -/// not escaped. -/// -/// # Examples -/// -/// ```rust -/// use helix_core::shellwords::ends_with_whitespace; -/// assert_eq!(ends_with_whitespace(" "), true); -/// assert_eq!(ends_with_whitespace(":open "), true); -/// assert_eq!(ends_with_whitespace(":open foo.txt "), true); -/// assert_eq!(ends_with_whitespace(":open"), false); -/// #[cfg(unix)] -/// assert_eq!(ends_with_whitespace(":open a\\ "), false); -/// #[cfg(unix)] -/// assert_eq!(ends_with_whitespace(":open a\\ b.txt"), false); -/// ``` -pub fn ends_with_whitespace(input: &str) -> bool { - use State::*; + debug_assert!(words.len() == parts.len()); - // Fast-lane: the input must end with a whitespace character - // regardless of quoting. - if !input.ends_with(|c: char| c.is_ascii_whitespace()) { - return false; + Self { + state, + words, + parts, + } } +} - let mut state = Unquoted; +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) + } - for c in input.chars() { - state = match state { - OnWhitespace => match c { - '"' => Dquoted, - '\'' => Quoted, - '\\' if cfg!(unix) => UnquotedEscaped, - '\\' => OnWhitespace, - c if c.is_ascii_whitespace() => OnWhitespace, - _ => Unquoted, - }, - Unquoted => match c { - '\\' if cfg!(unix) => UnquotedEscaped, - '\\' => Unquoted, - c if c.is_ascii_whitespace() => OnWhitespace, - _ => Unquoted, - }, - UnquotedEscaped => Unquoted, - Quoted => match c { - '\\' if cfg!(unix) => QuoteEscaped, - '\\' => Quoted, - '\'' => OnWhitespace, - _ => Quoted, - }, - QuoteEscaped => Quoted, - Dquoted => match c { - '\\' if cfg!(unix) => DquoteEscaped, - '\\' => Dquoted, - '"' => OnWhitespace, - _ => Dquoted, - }, - DquoteEscaped => Dquoted, - } + /// Returns the list of shellwords calculated from the input string. + pub fn words(&self) -> &[Cow<'a, str>] { + &self.words } - matches!(state, OnWhitespace) + /// 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 + } } #[cfg(test)] @@ -212,7 +204,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"), @@ -230,7 +223,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"), @@ -247,7 +241,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"), @@ -262,7 +257,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"), @@ -277,7 +273,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"), @@ -298,7 +295,8 @@ mod test { fn test_lists() { let input = r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#; - let result = shellwords(input); + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); let expected = vec![ Cow::from(":set"), Cow::from("statusline.center"), @@ -322,4 +320,31 @@ mod test { 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\\"]); + } + + #[test] + fn test_multibyte_at_end() { + assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]); + assert_eq!( + Shellwords::from(":sh echo 𒀀").parts(), + &[":sh", "echo", "𒀀"] + ); + assert_eq!( + Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(), + &[":sh", "echo", "𒀀", "hello", "world𒀀"] + ); + } } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index b320fcdabaa4..ca4da3dcd56d 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -7,8 +7,10 @@ use crate::{ Rope, RopeSlice, Tendril, }; +use ahash::RandomState; use arc_swap::{ArcSwap, Guard}; use bitflags::bitflags; +use hashbrown::raw::RawTable; use slotmap::{DefaultKey as LayerId, HopSlotMap}; use std::{ @@ -16,7 +18,8 @@ use std::{ cell::RefCell, collections::{HashMap, VecDeque}, fmt, - mem::replace, + hash::{Hash, Hasher}, + mem::{replace, transmute}, path::Path, str::FromStr, sync::Arc, @@ -204,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, @@ -354,24 +359,25 @@ impl<'a> CapturedNode<'a> { } } -/// The number of matches a TS cursor can at once to avoid performance problems for medium to large files. -/// Set with `set_match_limit`. -/// Using such a limit means that we lose valid captures in, so there is fundamentally a tradeoff here. +/// 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. /// /// -/// A value of 64 was chosen because neovim uses that value. -/// Neovim chose this value somewhat arbitrarily () adjusting it whenever issues occur in practice. -/// However this value has been in use for a long time and due to the large userbase of neovim it is probably a good choice. -/// If this limit causes problems for a grammar in the future, it could be increased. -const TREE_SITTER_MATCH_LIMIT: u32 = 64; +/// 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 @@ -769,30 +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 self.layers.values_mut() { - // The root layer always covers the whole range (0..usize::MAX) - if layer.depth == 0 { - layer.flags = LayerUpdateFlags::MODIFIED; - continue; - } + for (layer_id, layer) in self.layers.iter_mut() { + // The root layer always covers the whole range (0..usize::MAX) + if layer.depth == 0 { + layer.flags = LayerUpdateFlags::MODIFIED; + continue; + } + if !edits.is_empty() { for range in &mut layer.ranges { // Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720 for edit in edits.iter().rev() { @@ -857,6 +871,12 @@ impl Syntax { } } } + + let hash = layers_hasher.hash_one(layer); + // Safety: insert_no_grow is unsafe because it assumes that the table + // has enough capacity to hold additional elements. + // This is always the case as we reserved enough capacity above. + unsafe { layers_table.insert_no_grow(hash, layer_id) }; } PARSER.with(|ts_parser| { @@ -981,27 +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, - // set the modified flag to ensure the layer is parsed - flags: LayerUpdateFlags::empty(), - }) - }); + let layer_id = layer.unwrap_or_else(|| self.layers.insert(new_layer)); queue.push_back(layer_id); } @@ -1076,21 +1092,14 @@ impl Syntax { }], cursor, _tree: None, - captures, + captures: RefCell::new(captures), config: layer.config.as_ref(), // TODO: just reuse `layer` depth: layer.depth, // TODO: just reuse `layer` - ranges: &layer.ranges, // TODO: temp }) }) .collect::>(); - // HAXX: arrange layers by byte range, with deeper layers positioned first - layers.sort_by_key(|layer| { - ( - layer.ranges.first().cloned(), - std::cmp::Reverse(layer.depth), - ) - }); + layers.sort_unstable_by_key(|layer| layer.sort_key()); let mut result = HighlightIter { source, @@ -1138,6 +1147,34 @@ pub struct LanguageLayer { flags: LayerUpdateFlags, } +/// This PartialEq implementation only checks if that +/// two layers are theoretically identical (meaning they highlight the same text range with the same language). +/// It does not check whether the layers have the same internal treesitter +/// state. +impl PartialEq for LanguageLayer { + fn eq(&self, other: &Self) -> bool { + self.depth == other.depth + && self.config.language == other.config.language + && self.ranges == other.ranges + } +} + +/// Hash implementation belongs to PartialEq implementation above. +/// See its documentation for details. +impl Hash for LanguageLayer { + fn hash(&self, state: &mut H) { + self.depth.hash(state); + // The transmute is necessary here because tree_sitter::Language does not derive Hash at the moment. + // However it does use #[repr] transparent so the transmute here is safe + // as `Language` (which `Grammar` is an alias for) is just a newtype wrapper around a (thin) pointer. + // This is also compatible with the PartialEq implementation of language + // as that is just a pointer comparison. + let language: *const () = unsafe { transmute(self.config.language) }; + language.hash(state); + self.ranges.hash(state); + } +} + impl LanguageLayer { pub fn tree(&self) -> &Tree { // TODO: no unwrap @@ -1283,7 +1320,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; @@ -1380,12 +1417,11 @@ impl<'a> TextProvider<'a> for RopeProvider<'a> { struct HighlightIterLayer<'a> { _tree: Option, cursor: QueryCursor, - captures: iter::Peekable>>, + captures: RefCell>>>, config: &'a HighlightConfiguration, highlight_end_stack: Vec, scope_stack: Vec>, depth: u32, - ranges: &'a [Range], } impl<'a> fmt::Debug for HighlightIterLayer<'a> { @@ -1566,10 +1602,11 @@ impl<'a> HighlightIterLayer<'a> { // First, sort scope boundaries by their byte offset in the document. At a // given position, emit scope endings before scope beginnings. Finally, emit // scope boundaries from deeper layers first. - fn sort_key(&mut self) -> Option<(usize, bool, isize)> { + fn sort_key(&self) -> Option<(usize, bool, isize)> { let depth = -(self.depth as isize); let next_start = self .captures + .borrow_mut() .peek() .map(|(m, i)| m.captures[*i].node.start_byte()); let next_end = self.highlight_end_stack.last().cloned(); @@ -1794,7 +1831,8 @@ impl<'a> Iterator for HighlightIter<'a> { // Get the next capture from whichever layer has the earliest highlight boundary. let range; let layer = &mut self.layers[0]; - if let Some((next_match, capture_index)) = layer.captures.peek() { + let captures = layer.captures.get_mut(); + if let Some((next_match, capture_index)) = captures.peek() { let next_capture = next_match.captures[*capture_index]; range = next_capture.node.byte_range(); @@ -1817,7 +1855,7 @@ impl<'a> Iterator for HighlightIter<'a> { return self.emit_event(self.source.len_bytes(), None); }; - let (mut match_, capture_index) = layer.captures.next().unwrap(); + let (mut match_, capture_index) = captures.next().unwrap(); let mut capture = match_.captures[capture_index]; // Remove from the local scope stack any local scopes that have already ended. @@ -1893,11 +1931,11 @@ impl<'a> Iterator for HighlightIter<'a> { } // Continue processing any additional matches for the same node. - if let Some((next_match, next_capture_index)) = layer.captures.peek() { + if let Some((next_match, next_capture_index)) = captures.peek() { let next_capture = next_match.captures[*next_capture_index]; if next_capture.node == capture.node { capture = next_capture; - match_ = layer.captures.next().unwrap().0; + match_ = captures.next().unwrap().0; continue; } } @@ -1920,11 +1958,11 @@ impl<'a> Iterator for HighlightIter<'a> { // highlighting patterns that are disabled for local variables. if definition_highlight.is_some() || reference_highlight.is_some() { while layer.config.non_local_variable_patterns[match_.pattern_index] { - if let Some((next_match, next_capture_index)) = layer.captures.peek() { + if let Some((next_match, next_capture_index)) = captures.peek() { let next_capture = next_match.captures[*next_capture_index]; if next_capture.node == capture.node { capture = next_capture; - match_ = layer.captures.next().unwrap().0; + match_ = captures.next().unwrap().0; continue; } } @@ -1939,10 +1977,10 @@ impl<'a> Iterator for HighlightIter<'a> { // for a given node are ordered by pattern index, so these subsequent // captures are guaranteed to be for highlighting, not injections or // local variables. - while let Some((next_match, next_capture_index)) = layer.captures.peek() { + while let Some((next_match, next_capture_index)) = captures.peek() { let next_capture = next_match.captures[*next_capture_index]; if next_capture.node == capture.node { - layer.captures.next(); + captures.next(); } else { break; } @@ -2153,57 +2191,68 @@ impl> Iterator for Merge { } } +fn node_is_visible(node: &Node) -> bool { + node.is_missing() || (node.is_named() && node.language().node_kind_is_visible(node.kind_id())) +} + pub fn pretty_print_tree(fmt: &mut W, node: Node) -> fmt::Result { - pretty_print_tree_impl(fmt, node, true, None, 0) + if node.child_count() == 0 { + if node_is_visible(&node) { + write!(fmt, "({})", node.kind()) + } else { + write!(fmt, "\"{}\"", node.kind()) + } + } else { + pretty_print_tree_impl(fmt, &mut node.walk(), 0) + } } fn pretty_print_tree_impl( fmt: &mut W, - node: Node, - is_root: bool, - field_name: Option<&str>, + cursor: &mut TreeCursor, depth: usize, ) -> fmt::Result { - fn is_visible(node: Node) -> bool { - node.is_missing() - || (node.is_named() && node.language().node_kind_is_visible(node.kind_id())) - } + let node = cursor.node(); + let visible = node_is_visible(&node); - if is_visible(node) { + if visible { let indentation_columns = depth * 2; write!(fmt, "{:indentation_columns$}", "")?; - if let Some(field_name) = field_name { + if let Some(field_name) = cursor.field_name() { write!(fmt, "{}: ", field_name)?; } write!(fmt, "({}", node.kind())?; - } else if is_root { - write!(fmt, "(\"{}\")", node.kind())?; } - for child_idx in 0..node.child_count() { - if let Some(child) = node.child(child_idx) { - if is_visible(child) { + // Handle children. + if cursor.goto_first_child() { + loop { + if node_is_visible(&cursor.node()) { fmt.write_char('\n')?; } - pretty_print_tree_impl( - fmt, - child, - false, - node.field_name_for_child(child_idx as u32), - depth + 1, - )?; + pretty_print_tree_impl(fmt, cursor, depth + 1)?; + + if !cursor.goto_next_sibling() { + break; + } } + + let moved = cursor.goto_parent(); + // The parent of the first child must exist, and must be `node`. + debug_assert!(moved); + debug_assert!(cursor.node() == node); } - if is_visible(node) { - write!(fmt, ")")?; + if visible { + fmt.write_char(')')?; } Ok(()) } + #[cfg(test)] mod test { use super::*; @@ -2376,11 +2425,17 @@ mod test { } #[track_caller] - fn assert_pretty_print(source: &str, expected: &str, start: usize, end: usize) { + fn assert_pretty_print( + language_name: &str, + source: &str, + expected: &str, + start: usize, + end: usize, + ) { let source = Rope::from_str(source); let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language("rust").unwrap(); + let language = get_language(language_name).unwrap(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)); @@ -2400,13 +2455,14 @@ mod test { #[test] fn test_pretty_print() { let source = r#"/// Hello"#; - assert_pretty_print(source, "(line_comment)", 0, source.len()); + assert_pretty_print("rust", source, "(line_comment)", 0, source.len()); // A large tree should be indented with fields: let source = r#"fn main() { println!("Hello, World!"); }"#; assert_pretty_print( + "rust", source, concat!( "(function_item\n", @@ -2425,11 +2481,34 @@ mod test { // Selecting a token should print just that token: let source = r#"fn main() {}"#; - assert_pretty_print(source, r#"("fn")"#, 0, 1); + assert_pretty_print("rust", source, r#""fn""#, 0, 1); // Error nodes are printed as errors: let source = r#"}{"#; - assert_pretty_print(source, "(ERROR)", 0, source.len()); + assert_pretty_print("rust", source, "(ERROR)", 0, source.len()); + + // Fields broken under unnamed nodes are determined correctly. + // In the following source, `object` belongs to the `singleton_method` + // rule but `name` and `body` belong to an unnamed helper `_method_rest`. + // This can cause a bug with a pretty-printing implementation that + // uses `Node::field_name_for_child` to determine field names but is + // fixed when using `TreeCursor::field_name`. + let source = "def self.method_name + true + end"; + assert_pretty_print( + "ruby", + source, + concat!( + "(singleton_method\n", + " object: (self)\n", + " name: (identifier)\n", + " body: (body_statement\n", + " (true)))" + ), + 0, + source.len(), + ); } #[test] diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs new file mode 100644 index 000000000000..1956f6b5bf11 --- /dev/null +++ b/helix-core/src/text_annotations.rs @@ -0,0 +1,271 @@ +use std::cell::Cell; +use std::convert::identity; +use std::ops::Range; +use std::rc::Rc; + +use crate::syntax::Highlight; +use crate::Tendril; + +/// An inline annotation is continuous text shown +/// on the screen before the grapheme that starts at +/// `char_idx` +#[derive(Debug, Clone)] +pub struct InlineAnnotation { + pub text: Tendril, + pub char_idx: usize, +} + +/// Represents a **single Grapheme** that is part of the document +/// that start at `char_idx` that will be replaced with +/// a different `grapheme`. +/// If `grapheme` contains multiple graphemes the text +/// will render incorrectly. +/// If you want to overlay multiple graphemes simply +/// use multiple `Overlays`. +/// +/// # Examples +/// +/// The following examples are valid overlays for the following text: +/// +/// `aX͎̊͢͜͝͡bc` +/// +/// ``` +/// use helix_core::text_annotations::Overlay; +/// +/// // replaces a +/// Overlay { +/// char_idx: 0, +/// grapheme: "X".into(), +/// }; +/// +/// // replaces X͎̊͢͜͝͡ +/// Overlay{ +/// char_idx: 1, +/// grapheme: "\t".into(), +/// }; +/// +/// // replaces b +/// Overlay{ +/// char_idx: 6, +/// grapheme: "X̢̢̟͖̲͌̋̇͑͝".into(), +/// }; +/// ``` +/// +/// The following examples are invalid uses +/// +/// ``` +/// use helix_core::text_annotations::Overlay; +/// +/// // overlay is not aligned at grapheme boundary +/// Overlay{ +/// char_idx: 3, +/// grapheme: "x".into(), +/// }; +/// +/// // overlay contains multiple graphemes +/// Overlay{ +/// char_idx: 0, +/// grapheme: "xy".into(), +/// }; +/// ``` +#[derive(Debug, Clone)] +pub struct Overlay { + pub char_idx: usize, + pub grapheme: Tendril, +} + +/// Line annotations allow for virtual text between normal +/// text lines. They cause `height` empty lines to be inserted +/// below the document line that contains `anchor_char_idx`. +/// +/// These lines can be filled with text in the rendering code +/// as their contents have no effect beyond visual appearance. +/// +/// To insert a line after a document line simply set +/// `anchor_char_idx` to `doc.line_to_char(line_idx)` +#[derive(Debug, Clone)] +pub struct LineAnnotation { + pub anchor_char_idx: usize, + pub height: usize, +} + +#[derive(Debug)] +struct Layer { + annotations: Rc<[A]>, + current_index: Cell, + metadata: M, +} + +impl Clone for Layer { + fn clone(&self) -> Self { + Layer { + annotations: self.annotations.clone(), + current_index: self.current_index.clone(), + metadata: self.metadata.clone(), + } + } +} + +impl Layer { + pub fn reset_pos(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) { + let new_index = self + .annotations + .binary_search_by_key(&char_idx, get_char_idx) + .unwrap_or_else(identity); + + self.current_index.set(new_index); + } + + pub fn consume(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) -> Option<&A> { + let annot = self.annotations.get(self.current_index.get())?; + debug_assert!(get_char_idx(annot) >= char_idx); + if get_char_idx(annot) == char_idx { + self.current_index.set(self.current_index.get() + 1); + Some(annot) + } else { + None + } + } +} + +impl From<(Rc<[A]>, M)> for Layer { + fn from((annotations, metadata): (Rc<[A]>, M)) -> Layer { + Layer { + annotations, + current_index: Cell::new(0), + metadata, + } + } +} + +fn reset_pos(layers: &[Layer], pos: usize, get_pos: impl Fn(&A) -> usize) { + for layer in layers { + layer.reset_pos(pos, &get_pos) + } +} + +/// Annotations that change that is displayed when the document is render. +/// Also commonly called virtual text. +#[derive(Default, Debug, Clone)] +pub struct TextAnnotations { + inline_annotations: Vec>>, + overlays: Vec>>, + line_annotations: Vec>, +} + +impl TextAnnotations { + /// Prepare the TextAnnotations for iteration starting at char_idx + pub fn reset_pos(&self, char_idx: usize) { + reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx); + reset_pos(&self.overlays, char_idx, |annot| annot.char_idx); + reset_pos(&self.line_annotations, char_idx, |annot| { + annot.anchor_char_idx + }); + } + + pub fn collect_overlay_highlights( + &self, + char_range: Range, + ) -> Vec<(usize, Range)> { + let mut highlights = Vec::new(); + self.reset_pos(char_range.start); + for char_idx in char_range { + if let Some((_, Some(highlight))) = self.overlay_at(char_idx) { + // we don't know the number of chars the original grapheme takes + // however it doesn't matter as highlight bounderies are automatically + // aligned to grapheme boundaries in the rendering code + highlights.push((highlight.0, char_idx..char_idx + 1)) + } + } + + highlights + } + + /// Add new inline annotations. + /// + /// The annotations grapheme will be rendered with `highlight` + /// patched on top of `ui.text`. + /// + /// The annotations **must be sorted** by their `char_idx`. + /// Multiple annotations with the same `char_idx` are allowed, + /// they will be display in the order that they are present in the layer. + /// + /// If multiple layers contain annotations at the same position + /// the annotations that belong to the layers added first will be shown first. + pub fn add_inline_annotations( + &mut self, + layer: Rc<[InlineAnnotation]>, + highlight: Option, + ) -> &mut Self { + self.inline_annotations.push((layer, highlight).into()); + self + } + + /// Add new grapheme overlays. + /// + /// The overlayed grapheme will be rendered with `highlight` + /// patched on top of `ui.text`. + /// + /// The overlays **must be sorted** by their `char_idx`. + /// Multiple overlays with the same `char_idx` **are allowed**. + /// + /// If multiple layers contain overlay at the same position + /// the overlay from the layer added last will be show. + pub fn add_overlay(&mut self, layer: Rc<[Overlay]>, highlight: Option) -> &mut Self { + self.overlays.push((layer, highlight).into()); + self + } + + /// Add new annotation lines. + /// + /// The line annotations **must be sorted** by their `char_idx`. + /// Multiple line annotations with the same `char_idx` **are not allowed**. + pub fn add_line_annotation(&mut self, layer: Rc<[LineAnnotation]>) -> &mut Self { + self.line_annotations.push((layer, ()).into()); + self + } + + /// Removes all line annotations, useful for vertical motions + /// so that virtual text lines are automatically skipped. + pub fn clear_line_annotations(&mut self) { + self.line_annotations.clear(); + } + + pub(crate) fn next_inline_annotation_at( + &self, + char_idx: usize, + ) -> Option<(&InlineAnnotation, Option)> { + self.inline_annotations.iter().find_map(|layer| { + let annotation = layer.consume(char_idx, |annot| annot.char_idx)?; + Some((annotation, layer.metadata)) + }) + } + + pub(crate) fn overlay_at(&self, char_idx: usize) -> Option<(&Overlay, Option)> { + let mut overlay = None; + for layer in &self.overlays { + while let Some(new_overlay) = layer.consume(char_idx, |annot| annot.char_idx) { + overlay = Some((new_overlay, layer.metadata)); + } + } + overlay + } + + pub(crate) fn annotation_lines_at(&self, char_idx: usize) -> usize { + self.line_annotations + .iter() + .map(|layer| { + let mut lines = 0; + while let Some(annot) = layer.annotations.get(layer.current_index.get()) { + if annot.anchor_char_idx == char_idx { + layer.current_index.set(layer.current_index.get() + 1); + lines += annot.height + } else { + break; + } + } + lines + }) + .sum() + } +} diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index 3fb394138a1e..482fd6d97e5e 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; diff --git a/helix-core/tests/indent.rs b/helix-core/tests/indent.rs index e1114f4a9e25..f74b576ac996 100644 --- a/helix-core/tests/indent.rs +++ b/helix-core/tests/indent.rs @@ -28,8 +28,8 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) { let mut config_file = test_dir; config_file.push("languages.toml"); - let config = std::fs::read(config_file).unwrap(); - let config = toml::from_slice(&config).unwrap(); + let config = std::fs::read_to_string(config_file).unwrap(); + let config = toml::from_str(&config).unwrap(); let loader = Loader::new(config); // set runtime path so we can find the queries diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index 95a059052775..d42ce23ffd16 100644 --- a/helix-dap/Cargo.toml +++ b/helix-dap/Cargo.toml @@ -19,7 +19,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "net", "sync"] } -which = "4.2" +which = "4.4" [dev-dependencies] fern = "0.6" diff --git a/helix-dap/src/client.rs b/helix-dap/src/client.rs index 371cf3032e96..e72d290e3fd5 100644 --- a/helix-dap/src/client.rs +++ b/helix-dap/src/client.rs @@ -70,7 +70,7 @@ impl Client { process: Option, ) -> Result<(Self, UnboundedReceiver)> { let (server_rx, server_tx) = Transport::start(rx, tx, err, id); - let (client_rx, client_tx) = unbounded_channel(); + let (client_tx, client_rx) = unbounded_channel(); let client = Self { id, @@ -86,9 +86,9 @@ impl Client { quirks: DebuggerQuirks::default(), }; - tokio::spawn(Self::recv(server_rx, client_rx)); + tokio::spawn(Self::recv(server_rx, client_tx)); - Ok((client, client_tx)) + Ok((client, client_rx)) } pub async fn tcp( diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index 760205e1f208..9225ad1a2235 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -16,10 +16,10 @@ path = "src/main.rs" [dependencies] anyhow = "1" serde = { version = "1.0", features = ["derive"] } -toml = "0.5" +toml = "0.7" etcetera = "0.4" tree-sitter = "0.20" -once_cell = "1.16" +once_cell = "1.17" log = "0.4" # TODO: these two should be on !wasm32 only diff --git a/helix-loader/build.rs b/helix-loader/build.rs index e0ebd1c48f17..c4b89e6b93fe 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/config.rs b/helix-loader/src/config.rs index 259b1318ea00..0f329d217c0f 100644 --- a/helix-loader/src/config.rs +++ b/helix-loader/src/config.rs @@ -1,6 +1,9 @@ +use std::str::from_utf8; + /// Default built-in languages.toml. pub fn default_lang_config() -> toml::Value { - toml::from_slice(include_bytes!("../../languages.toml")) + let default_config = include_bytes!("../../languages.toml"); + toml::from_str(from_utf8(default_config).unwrap()) .expect("Could not parse built-in languages.toml to valid toml") } @@ -11,8 +14,8 @@ pub fn user_lang_config() -> Result { .chain([crate::config_dir()].into_iter()) .map(|path| path.join("languages.toml")) .filter_map(|file| { - std::fs::read(&file) - .map(|config| toml::from_slice(&config)) + std::fs::read_to_string(file) + .map(|config| toml::from_str(&config)) .ok() }) .collect::, _>>()? diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index 833616e046d9..01c966c8c4bd 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -263,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"])?; } @@ -515,5 +515,5 @@ pub fn load_runtime_file(language: &str, filename: &str) -> Result = once_cell::sync::Lazy::new(runtime_dir); static CONFIG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); @@ -95,7 +97,7 @@ pub fn find_local_config_dirs() -> Vec { let mut directories = Vec::new(); for ancestor in current_dir.ancestors() { - if ancestor.join(".git").is_dir() { + if ancestor.join(".git").exists() { directories.push(ancestor.to_path_buf()); // Don't go higher than repo if we're in one break; @@ -177,6 +179,8 @@ pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usi #[cfg(test)] mod merge_toml_tests { + use std::str; + use super::merge_toml_values; use toml::Value; @@ -189,8 +193,9 @@ mod merge_toml_tests { indent = { tab-width = 4, unit = " ", test = "aaa" } "#; - let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Couldn't parse built-in languages config"); + let base = include_bytes!("../../languages.toml"); + let base = str::from_utf8(base).expect("Couldn't parse built-in languages config"); + let base: Value = toml::from_str(base).expect("Couldn't parse built-in languages config"); let user: Value = toml::from_str(USER).unwrap(); let merged = merge_toml_values(base, user, 3); @@ -222,8 +227,9 @@ mod merge_toml_tests { language-server = { command = "deno", args = ["lsp"] } "#; - let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Couldn't parse built-in languages config"); + let base = include_bytes!("../../languages.toml"); + let base = str::from_utf8(base).expect("Couldn't parse built-in languages config"); + let base: Value = toml::from_str(base).expect("Couldn't parse built-in languages config"); let user: Value = toml::from_str(USER).unwrap(); let merged = merge_toml_values(base, user, 3); diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index ad432d96f064..0db61ad4a5ab 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 = { version = "1.24", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.11" -which = "4.2" +which = "4.4" diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 50c4b87f2e78..6827f568d986 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; @@ -42,10 +42,12 @@ 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, @@ -55,6 +57,7 @@ impl Client { 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()) @@ -377,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 }; @@ -546,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, }; @@ -631,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, @@ -647,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( @@ -666,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 { @@ -687,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, @@ -697,7 +729,7 @@ impl Client { // lsp::SignatureHelpContext }; - self.call::(params) + Some(self.call::(params)) } // formatting @@ -710,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 @@ -751,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, @@ -775,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( @@ -787,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, @@ -799,7 +837,7 @@ impl Client { }, }; - self.call::(params) + Some(self.call::(params)) } fn goto_request< @@ -832,8 +870,45 @@ 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_declaration( + &self, + text_document: lsp::TextDocumentIdentifier, + position: lsp::Position, + work_done_token: Option, + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support goto-declaration. + match capabilities.declaration_provider { + Some( + lsp::DeclarationCapability::Simple(true) + | lsp::DeclarationCapability::RegistrationOptions(_) + | lsp::DeclarationCapability::Options(_), + ) => (), + _ => return None, + } + + Some(self.goto_request::( + text_document, + position, + work_done_token, + )) } pub fn goto_type_definition( @@ -841,12 +916,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( @@ -854,12 +940,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( @@ -867,7 +964,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, @@ -882,31 +987,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( @@ -914,7 +1035,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, @@ -923,26 +1055,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 { @@ -956,11 +1084,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(), @@ -969,6 +1107,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 75ac9309bdf0..69d02707e17a 100644 --- a/helix-lsp/src/jsonrpc.rs +++ b/helix-lsp/src/jsonrpc.rs @@ -170,6 +170,10 @@ impl Params { serde_json::from_value(value) .map_err(|err| Error::invalid_params(format!("Invalid params: {}.", err))) } + + pub fn is_none(&self) -> bool { + self == &Params::None + } } impl From for Value { @@ -187,7 +191,7 @@ impl From for Value { pub struct MethodCall { pub jsonrpc: Option, pub method: String, - #[serde(default = "default_params")] + #[serde(default = "default_params", skip_serializing_if = "Params::is_none")] pub params: Params, pub id: Id, } @@ -197,7 +201,7 @@ pub struct MethodCall { pub struct Notification { pub jsonrpc: Option, pub method: String, - #[serde(default = "default_params")] + #[serde(default = "default_params", skip_serializing_if = "Params::is_none")] pub params: Params, } @@ -334,6 +338,33 @@ fn notification_serialize() { ); } +#[test] +fn serialize_skip_none_params() { + use serde_json; + + let m = MethodCall { + jsonrpc: Some(Version::V2), + method: "shutdown".to_owned(), + params: Params::None, + id: Id::Num(1), + }; + + let serialized = serde_json::to_string(&m).unwrap(); + assert_eq!( + serialized, + r#"{"jsonrpc":"2.0","method":"shutdown","id":1}"# + ); + + let n = Notification { + jsonrpc: Some(Version::V2), + method: "exit".to_owned(), + params: Params::None, + }; + + let serialized = serde_json::to_string(&n).unwrap(); + assert_eq!(serialized, r#"{"jsonrpc":"2.0","method":"exit"}"#); +} + #[test] fn success_output_deserialize() { use serde_json; diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index feeedc96c0ec..8418896cbb73 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -57,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`]. /// @@ -102,16 +102,17 @@ pub mod util { None }; - // TODO: add support for Diagnostic.data - lsp::Diagnostic::new( - range_to_lsp_range(doc, range, offset_encoding), + lsp::Diagnostic { + range: range_to_lsp_range(doc, range, offset_encoding), severity, code, - diag.source.clone(), - diag.message.to_owned(), - 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. @@ -195,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, @@ -282,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), @@ -294,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) @@ -350,6 +390,10 @@ impl Registry { .map(|(_, client)| client.as_ref()) } + 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, @@ -506,6 +550,7 @@ fn start_client( &ls_config.command, &ls_config.args, config.config.clone(), + ls_config.environment.clone(), &config.roots, id, ls_config.timeout, diff --git a/helix-lsp/src/transport.rs b/helix-lsp/src/transport.rs index 8aaeae3d9591..3e3e06eec4c5 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 485cabe90819..603f37d39ab8 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,12 +31,13 @@ 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.16" +once_cell = "1.17" -which = "4.2" +which = "4.4" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] } @@ -42,7 +45,7 @@ crossterm = { version = "0.25", features = ["event-stream"] } signal-hook = "0.3" tokio-stream = "0.1" futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } -arc-swap = { version = "1.5.1" } +arc-swap = { version = "1.6.0" } # Logging fern = "0.6" @@ -58,7 +61,7 @@ pulldown-cmark = { version = "0.9", default-features = false } content_inspector = "0.2.4" # config -toml = "0.5" +toml = "0.7" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } @@ -75,5 +78,5 @@ helix-loader = { version = "0.6", path = "../helix-loader" } [dev-dependencies] smallvec = "1.10" -indoc = "1.0.6" +indoc = "2.0.0" tempfile = "3.3.0" diff --git a/helix-term/build.rs b/helix-term/build.rs index 719113ff2c37..b47dae8ef653 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 39a6532d7d5f..05ceb874e84d 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -172,7 +172,7 @@ impl Application { area, theme_loader.clone(), syn_loader.clone(), - Box::new(Map::new(Arc::clone(&config), |config: &Config| { + Arc::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), ); @@ -227,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); @@ -274,16 +278,27 @@ impl Application { } #[cfg(feature = "integration")] - fn render(&mut self) {} + async fn render(&mut self) {} #[cfg(not(feature = "integration"))] - fn render(&mut self) { + async fn render(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; + // 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() @@ -294,8 +309,10 @@ impl Application { let surface = self.terminal.current_buffer_mut(); self.compositor.render(area, surface, &mut cx); - let (pos, kind) = self.compositor.cursor(area, &self.editor); + // reset cursor cache + self.editor.cursor_cache.set(None); + let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); self.terminal.draw(pos, kind).unwrap(); } @@ -304,7 +321,7 @@ impl Application { where S: Stream> + Unpin, { - self.render(); + self.render().await; self.last_render = Instant::now(); loop { @@ -329,18 +346,18 @@ 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(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; } event = self.editor.wait_event() => { let _idle_handled = self.handle_editor_event(event).await; @@ -380,36 +397,62 @@ impl Application { // Update all the relevant members in the editor after updating // the configuration. self.editor.refresh_config(); + + // reset view position in case softwrap was enabled/disabled + let scrolloff = self.editor.config().scrolloff; + for (view, _) in self.editor.tree.views_mut() { + let doc = &self.editor.documents[&view.doc]; + view.ensure_cursor_in_view(doc, scrolloff) + } + } + + /// refresh language config after config change + fn refresh_language_config(&mut self) -> Result<(), Error> { + let syntax_config = helix_core::config::user_syntax_loader() + .map_err(|err| anyhow::anyhow!("Failed to load language config: {}", err))?; + + self.syn_loader = std::sync::Arc::new(syntax::Loader::new(syntax_config)); + self.editor.syn_loader = self.syn_loader.clone(); + for document in self.editor.documents.values_mut() { + document.detect_language(self.syn_loader.clone()); + } + + Ok(()) } /// Refresh theme after config change - fn refresh_theme(&mut self, config: &Config) { + fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> { if let Some(theme) = config.theme.clone() { let true_color = self.true_color(); - match self.theme_loader.load(&theme) { - Ok(theme) => { - if true_color || theme.is_16_color() { - self.editor.set_theme(theme); - } else { - self.editor - .set_error("theme requires truecolor support, which is not available"); - } - } - Err(err) => { - let err_string = format!("failed to load theme `{}` - {}", theme, err); - self.editor.set_error(err_string); - } + let theme = self + .theme_loader + .load(&theme) + .map_err(|err| anyhow::anyhow!("Failed to load theme `{}`: {}", theme, err))?; + + if true_color || theme.is_16_color() { + self.editor.set_theme(theme); + } else { + anyhow::bail!("theme requires truecolor support, which is not available") } } + + Ok(()) } fn refresh_config(&mut self) { - match Config::load_default() { - Ok(config) => { - self.refresh_theme(&config); + let mut refresh_config = || -> Result<(), Error> { + let default_config = Config::load_default() + .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; + self.refresh_language_config()?; + self.refresh_theme(&default_config)?; + // Store new config + self.config.store(Arc::new(default_config)); + Ok(()) + }; - // Store new config - self.config.store(Arc::new(config)); + match refresh_config() { + Ok(_) => { + self.editor.set_status("Config refreshed"); } Err(err) => { self.editor.set_error(err.to_string()); @@ -445,25 +488,25 @@ impl Application { self.compositor.resize(area); self.terminal.clear().expect("couldn't clear terminal"); - self.render(); + self.render().await; } signal::SIGUSR1 => { self.refresh_config(); - self.render(); + self.render().await; } _ => unreachable!(), } } - pub fn handle_idle_timeout(&mut self) { + pub async fn handle_idle_timeout(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx); - if should_render { - self.render(); + if should_render || self.editor.needs_redraw { + self.render().await; } } @@ -536,11 +579,11 @@ impl Application { match event { EditorEvent::DocumentSaved(event) => { self.handle_document_write(event); - self.render(); + self.render().await; } EditorEvent::ConfigEvent(event) => { self.handle_config_events(event); - self.render(); + self.render().await; } EditorEvent::LanguageServerMessage((id, call)) => { self.handle_language_server_message(call, id).await; @@ -548,19 +591,19 @@ impl Application { let last = self.editor.language_servers.incoming.is_empty(); if last || self.last_render.elapsed() > LSP_DEADLINE { - self.render(); + 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(); + self.render().await; } } EditorEvent::IdleTimer => { self.editor.clear_idle_timer(); - self.handle_idle_timeout(); + self.handle_idle_timeout().await; #[cfg(feature = "integration")] { @@ -572,7 +615,10 @@ impl Application { false } - pub fn handle_terminal_events(&mut self, event: Result) { + pub async fn handle_terminal_events( + &mut self, + event: Result, + ) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, @@ -596,7 +642,7 @@ impl Application { }; if should_redraw && !self.editor.should_close() { - self.render(); + self.render().await; } } @@ -758,7 +804,8 @@ impl Application { severity, code, tags, - source: diagnostic.source.clone() + source: diagnostic.source.clone(), + data: diagnostic.data.clone(), }) }) .collect(); @@ -871,6 +918,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 { diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index c74dfb39cefc..d1dc92236cf0 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3,31 +3,33 @@ pub(crate) mod lsp; pub(crate) mod typed; pub use dap::*; +use helix_vcs::Hunk; pub use lsp::*; -use tui::text::Spans; +use tui::widgets::Row; pub use typed::*; use helix_core::{ - comment, coords_at_pos, encoding, find_first_non_whitespace_char, find_root, graphemes, + char_idx_at_visual_offset, comment, + doc_formatter::TextFormat, + encoding, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, - increment::date_time::DateTimeIncrementor, - increment::{number::NumberIncrementor, Increment}, - indent, + increment, indent, indent::IndentStyle, line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, - movement::{self, Direction}, - object, pos_at_coords, pos_at_visual_coords, + movement::{self, move_vertically_visual, Direction}, + object, pos_at_coords, regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, - selection, shellwords, surround, textobject, + selection, shellwords, surround, + text_annotations::TextAnnotations, + textobject, tree_sitter::Node, unicode::width::UnicodeWidthChar, - visual_coords_at_pos, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, - SmallVec, Tendril, Transaction, + visual_offset_from_block, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, + Selection, SmallVec, Tendril, Transaction, }; use helix_view::{ - apply_transaction, clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::{Action, Motion}, @@ -47,6 +49,7 @@ use movement::Movement; use crate::{ args, compositor::{self, Component, Compositor}, + filter_picker_entry, job::Callback, keymap::ReverseKeymap, ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, @@ -54,7 +57,7 @@ use crate::{ use crate::job::{self, Jobs}; use futures_util::StreamExt; -use std::{collections::HashMap, fmt, fmt::Write, future::Future}; +use std::{collections::HashMap, fmt, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; use std::{ @@ -202,10 +205,14 @@ impl MappableCommand { move_char_right, "Move right", move_line_up, "Move up", move_line_down, "Move down", + move_visual_line_up, "Move up", + move_visual_line_down, "Move down", extend_char_left, "Extend left", extend_char_right, "Extend right", extend_line_up, "Extend up", extend_line_down, "Extend down", + extend_visual_line_up, "Extend up", + extend_visual_line_down, "Extend down", copy_selection_on_next_line, "Copy selection on next line", copy_selection_on_prev_line, "Copy selection on previous line", move_next_word_start, "Move to start of next word", @@ -243,6 +250,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", @@ -250,6 +258,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", @@ -267,6 +276,7 @@ impl MappableCommand { append_mode, "Append after selection", command_mode, "Enter command mode", file_picker, "Open file picker", + file_picker_in_current_buffer_directory, "Open file picker at current buffers's directory", file_picker_in_current_directory, "Open file picker at current working directory", code_action, "Perform code action", buffer_picker, "Open buffer picker", @@ -285,6 +295,7 @@ impl MappableCommand { select_mode, "Enter selection extend mode", exit_select_mode, "Exit selection mode", goto_definition, "Goto definition", + goto_declaration, "Goto declaration", add_newline_above, "Add newline above", add_newline_below, "Add newline below", goto_type_definition, "Goto type definition", @@ -307,6 +318,10 @@ 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", @@ -379,6 +394,7 @@ impl MappableCommand { swap_view_down, "Swap with split below", transpose_view, "Transpose splits", rotate_view, "Goto next window", + rotate_view_reverse, "Goto previous window", hsplit, "Horizontal bottom split", hsplit_new, "Horizontal bottom split scratch buffer", vsplit, "Vertical right split", @@ -401,8 +417,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", @@ -443,9 +459,16 @@ impl MappableCommand { impl fmt::Debug for MappableCommand { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("MappableCommand") - .field(&self.name()) - .finish() + match self { + MappableCommand::Static { name, .. } => { + f.debug_tuple("MappableCommand").field(name).finish() + } + MappableCommand::Typable { name, args, .. } => f + .debug_tuple("MappableCommand") + .field(name) + .field(args) + .finish(), + } } } @@ -500,12 +523,16 @@ impl PartialEq for MappableCommand { match (self, other) { ( MappableCommand::Typable { - name: first_name, .. + name: first_name, + args: first_args, + .. }, MappableCommand::Typable { - name: second_name, .. + name: second_name, + args: second_args, + .. }, - ) => first_name == second_name, + ) => first_name == second_name && first_args == second_args, ( MappableCommand::Static { name: first_name, .. @@ -521,18 +548,27 @@ impl PartialEq for MappableCommand { fn no_op(_cx: &mut Context) {} -fn move_impl(cx: &mut Context, move_fn: F, dir: Direction, behaviour: Movement) -where - F: Fn(RopeSlice, Range, Direction, usize, Movement, usize) -> Range, -{ +type MoveFn = + fn(RopeSlice, Range, Direction, usize, Movement, &TextFormat, &mut TextAnnotations) -> Range; + +fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movement) { let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); + let text_fmt = doc.text_format(view.inner_area(doc).width, None); + let mut annotations = view.text_annotations(doc, None); - let selection = doc - .selection(view.id) - .clone() - .transform(|range| move_fn(text, range, dir, count, behaviour, doc.tab_width())); + let selection = doc.selection(view.id).clone().transform(|range| { + move_fn( + text, + range, + dir, + count, + behaviour, + &text_fmt, + &mut annotations, + ) + }); doc.set_selection(view.id, selection); } @@ -554,6 +590,24 @@ fn move_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Move) } +fn move_visual_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Backward, + Movement::Move, + ) +} + +fn move_visual_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Forward, + Movement::Move, + ) +} + fn extend_char_left(cx: &mut Context) { move_impl(cx, move_horizontally, Direction::Backward, Movement::Extend) } @@ -570,6 +624,24 @@ fn extend_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Extend) } +fn extend_visual_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Backward, + Movement::Extend, + ) +} + +fn extend_visual_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_visual, + Direction::Forward, + Movement::Extend, + ) +} + fn goto_line_end_impl(view: &mut View, doc: &mut Document, movement: Movement) { let text = doc.text().slice(..); @@ -779,11 +851,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(); @@ -801,7 +869,10 @@ fn trim_selections(cx: &mut Context) { } // align text in selection +#[allow(deprecated)] fn align_selections(cx: &mut Context) { + use helix_core::visual_coords_at_pos; + let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let selection = doc.selection(view.id); @@ -862,7 +933,7 @@ fn align_selections(cx: &mut Context) { changes.sort_unstable_by_key(|(from, _, _)| *from); let transaction = Transaction::change(doc.text(), changes.into_iter()); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn goto_window(cx: &mut Context, align: Align) { @@ -878,17 +949,22 @@ fn goto_window(cx: &mut Context, align: Align) { // as we type let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2); - let last_line = view.last_line(doc); + let last_visual_line = view.last_visual_line(doc); - let line = match align { - Align::Top => view.offset.row + scrolloff + count, - Align::Center => view.offset.row + ((last_line - view.offset.row) / 2), - Align::Bottom => last_line.saturating_sub(scrolloff + count), + let visual_line = match align { + Align::Top => view.offset.vertical_offset + scrolloff + count, + Align::Center => view.offset.vertical_offset + (last_visual_line / 2), + Align::Bottom => { + view.offset.vertical_offset + last_visual_line.saturating_sub(scrolloff + count) + } } - .max(view.offset.row + scrolloff) - .min(last_line.saturating_sub(scrolloff)); + .max(view.offset.vertical_offset + scrolloff) + .min(view.offset.vertical_offset + last_visual_line.saturating_sub(scrolloff)); + + let pos = view + .pos_at_visual_coords(doc, visual_line as u16, 0, false) + .expect("visual_line was constrained to the view area"); - let pos = doc.text().line_to_char(line); let text = doc.text().slice(..); let selection = doc .selection(view.id) @@ -1134,6 +1210,10 @@ where doc!(cx.editor).line_ending.as_str().chars().next().unwrap() } + KeyEvent { + code: KeyCode::Tab, .. + } => '\t', + KeyEvent { code: KeyCode::Char(ch), .. @@ -1280,6 +1360,9 @@ fn replace(cx: &mut Context) { code: KeyCode::Enter, .. } => Some(doc.line_ending.as_str()), + KeyEvent { + code: KeyCode::Tab, .. + } => Some("\t"), _ => None, }; @@ -1307,7 +1390,7 @@ fn replace(cx: &mut Context) { } }); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); exit_select_mode(cx); } }) @@ -1325,7 +1408,7 @@ where (range.from(), range.to(), Some(text)) }); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn switch_case(cx: &mut Context) { @@ -1365,53 +1448,72 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let range = doc.selection(view.id).primary(); let text = doc.text().slice(..); - let cursor = visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); - let doc_last_line = doc.text().len_lines().saturating_sub(1); - - let last_line = view.last_line(doc); - - if direction == Backward && view.offset.row == 0 - || direction == Forward && last_line == doc_last_line - { - return; - } - + let cursor = range.cursor(text); let height = view.inner_height(); let scrolloff = config.scrolloff.min(height / 2); + let offset = match direction { + Forward => offset as isize, + Backward => -(offset as isize), + }; - view.offset.row = match direction { - Forward => view.offset.row + offset, - Backward => view.offset.row.saturating_sub(offset), - } - .min(doc_last_line); - - // recalculate last line - let last_line = view.last_line(doc); - - // clamp into viewport - let line = cursor - .row - .max(view.offset.row + scrolloff) - .min(last_line.saturating_sub(scrolloff)); + let doc_text = doc.text().slice(..); + let viewport = view.inner_area(doc); + let text_fmt = doc.text_format(viewport.width, None); + let annotations = view.text_annotations(doc, None); + (view.offset.anchor, view.offset.vertical_offset) = char_idx_at_visual_offset( + doc_text, + view.offset.anchor, + view.offset.vertical_offset as isize + offset, + 0, + &text_fmt, + &annotations, + ); - // If cursor needs moving, replace primary selection - if line != cursor.row { - let head = pos_at_visual_coords(text, Position::new(line, cursor.col), doc.tab_width()); // this func will properly truncate to line end + let head; + match direction { + Forward => { + head = char_idx_at_visual_offset( + doc_text, + view.offset.anchor, + (view.offset.vertical_offset + scrolloff) as isize, + 0, + &text_fmt, + &annotations, + ) + .0; + if head <= cursor { + return; + } + } + Backward => { + head = char_idx_at_visual_offset( + doc_text, + view.offset.anchor, + (view.offset.vertical_offset + height - scrolloff) as isize, + 0, + &text_fmt, + &annotations, + ) + .0; + if head >= cursor { + return; + } + } + } - let anchor = if cx.editor.mode == Mode::Select { - range.anchor - } else { - head - }; + let anchor = if cx.editor.mode == Mode::Select { + range.anchor + } else { + head + }; - // replace primary selection with an empty selection at cursor pos - let prim_sel = Range::new(anchor, head); - let mut sel = doc.selection(view.id).clone(); - let idx = sel.primary_index(); - sel = sel.replace(idx, prim_sel); - doc.set_selection(view.id, sel); - } + // replace primary selection with an empty selection at cursor pos + let prim_sel = Range::new(anchor, head); + let mut sel = doc.selection(view.id).clone(); + let idx = sel.primary_index(); + sel = sel.replace(idx, prim_sel); + doc.set_selection(view.id, sel); } fn page_up(cx: &mut Context) { @@ -1438,7 +1540,15 @@ fn half_page_down(cx: &mut Context) { scroll(cx, offset, Direction::Forward); } +#[allow(deprecated)] +// currently uses the deprected `visual_coords_at_pos`/`pos_at_visual_coords` functions +// as this function ignores softwrapping (and virtual text) and instead only cares +// about "text visual position" +// +// TODO: implement a variant of that uses visual lines and respects virtual text fn copy_selection_on_line(cx: &mut Context, direction: Direction) { + use helix_core::{pos_at_visual_coords, visual_coords_at_pos}; + let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -1580,6 +1690,12 @@ 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( editor: &mut Editor, @@ -1655,11 +1771,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), @@ -1667,12 +1779,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); }; } @@ -1805,7 +1912,36 @@ fn search_selection(cx: &mut Context) { .join("|"); let msg = format!("register '{}' set to '{}'", '/', ®ex); - cx.editor.registers.get_mut('/').push(regex); + cx.editor.registers.push('/', regex); + cx.editor.set_status(msg); +} + +fn make_search_word_bounded(cx: &mut Context) { + let regex = match cx.editor.registers.last('/') { + Some(regex) => regex, + None => return, + }; + let start_anchored = regex.starts_with("\\b"); + let end_anchored = regex.ends_with("\\b"); + + if start_anchored && end_anchored { + return; + } + + let mut new_regex = String::with_capacity( + regex.len() + if start_anchored { 0 } else { 2 } + if end_anchored { 0 } else { 2 }, + ); + + if !start_anchored { + new_regex.push_str("\\b"); + } + new_regex.push_str(regex); + if !end_anchored { + new_regex.push_str("\\b"); + } + + let msg = format!("register '{}' set to '{}'", '/', &new_regex); + cx.editor.registers.push('/', new_regex); cx.editor.set_status(msg); } @@ -1829,7 +1965,7 @@ fn global_search(cx: &mut Context) { impl ui::menu::Item for FileResult { type Data = Option; - fn label(&self, current_path: &Self::Data) -> Spans { + fn format(&self, current_path: &Self::Data) -> Row { let relative_path = helix_core::path::get_relative_path(&self.path) .to_string_lossy() .into_owned(); @@ -1879,6 +2015,11 @@ fn global_search(cx: &mut Context) { let search_root = std::env::current_dir() .expect("Global search error: Failed to get current dir"); + let dedup_symlinks = file_picker_config.deduplicate_links; + let absolute_root = search_root + .canonicalize() + .unwrap_or_else(|_| search_root.clone()); + WalkBuilder::new(search_root) .hidden(file_picker_config.hidden) .parents(file_picker_config.parents) @@ -1888,10 +2029,9 @@ fn global_search(cx: &mut Context) { .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") + .filter_entry(move |entry| { + filter_picker_entry(entry, &absolute_root, dedup_symlinks) + }) .build_parallel() .run(|| { let mut searcher = searcher.clone(); @@ -1969,6 +2109,10 @@ fn global_search(cx: &mut Context) { let line_num = *line_num; let (view, doc) = current!(cx.editor); let text = doc.text(); + if line_num >= text.len_lines() { + cx.editor.set_error("The line you jumped to does not exist anymore because the file has changed."); + return; + } let start = text.line_to_char(line_num); let end = text.line_to_char((line_num + 1).min(text.len_lines())); @@ -1976,7 +2120,7 @@ fn global_search(cx: &mut Context) { align_view(doc, view, Align::Center); }, |_editor, FileResult { path, line_num }| { - Some((path.clone(), Some((*line_num, *line_num)))) + Some((path.clone().into(), Some((*line_num, *line_num)))) }, ); compositor.push(Box::new(overlayed(picker))); @@ -2017,16 +2161,10 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { let selection = doc.selection(view.id).clone().transform(|range| { let (start_line, end_line) = range.line_range(text.slice(..)); - let start = text.line_to_char(match extend { - Extend::Above => start_line.saturating_sub(count - 1), - Extend::Below => start_line, - }); + let start = text.line_to_char(start_line); let end = text.line_to_char( - match extend { - Extend::Above => end_line + 1, // the start of next line - Extend::Below => end_line + count, - } - .min(text.len_lines()), + (end_line + 1) // newline of end_line + .min(text.len_lines()), ); // extend to previous/next line if current line is selected @@ -2040,8 +2178,11 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { } } else { match extend { - Extend::Above => (end, start), - Extend::Below => (start, end), + Extend::Above => (end, text.line_to_char(start_line.saturating_sub(count - 1))), + Extend::Below => ( + start, + text.line_to_char((end_line + count).min(text.len_lines())), + ), } }; @@ -2063,11 +2204,7 @@ fn extend_to_line_bounds(cx: &mut Context) { let start = text.line_to_char(start_line); let end = text.line_to_char((end_line + 1).min(text.len_lines())); - if range.anchor <= range.head { - Range::new(start, end) - } else { - Range::new(end, start) - } + Range::new(start, end).with_direction(range.direction()) }), ); } @@ -2104,11 +2241,7 @@ fn shrink_to_line_bounds(cx: &mut Context) { end = text.line_to_char(end_line); } - if range.anchor <= range.head { - Range::new(start, end) - } else { - Range::new(end, start) - } + Range::new(start, end).with_direction(range.direction()) }), ); } @@ -2121,23 +2254,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) }); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); match op { Operation::Delete => { @@ -2155,7 +2286,7 @@ fn delete_selection_insert_mode(doc: &mut Document, view: &mut View, selection: let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn delete_selection(cx: &mut Context) { @@ -2251,7 +2382,7 @@ fn append_mode(cx: &mut Context) { doc.text(), [(end, end, Some(doc.line_ending.as_str().into()))].into_iter(), ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } let selection = doc.selection(view.id).clone().transform(|range| { @@ -2271,6 +2402,22 @@ fn file_picker(cx: &mut Context) { cx.push_layer(Box::new(overlayed(picker))); } +fn file_picker_in_current_buffer_directory(cx: &mut Context) { + let doc_dir = doc!(cx.editor) + .path() + .and_then(|path| path.parent().map(|path| path.to_path_buf())); + + let path = match doc_dir { + Some(path) => path, + None => { + cx.editor.set_error("current buffer has no path or parent"); + return; + } + }; + + let picker = ui::file_picker(path, &cx.editor.config()); + cx.push_layer(Box::new(overlayed(picker))); +} fn file_picker_in_current_directory(cx: &mut Context) { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("./")); let picker = ui::file_picker(cwd, &cx.editor.config()); @@ -2290,7 +2437,7 @@ fn buffer_picker(cx: &mut Context) { impl ui::menu::Item for BufferMeta { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { let path = self .path .as_deref() @@ -2300,20 +2447,15 @@ fn buffer_picker(cx: &mut Context) { None => SCRATCH_BUFFER_NAME, }; - let mut flags = Vec::new(); + let mut flags = String::new(); if self.is_modified { - flags.push("+"); + flags.push('+'); } if self.is_current { - flags.push("*"); + flags.push('*'); } - let flag = if flags.is_empty() { - "".into() - } else { - format!(" ({})", flags.join("")) - }; - format!("{} {}{}", self.id, path, flag).into() + Row::new([self.id.to_string(), flags, path.to_string()]) } } @@ -2327,8 +2469,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| { @@ -2341,7 +2483,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))); @@ -2359,7 +2501,7 @@ fn jumplist_picker(cx: &mut Context) { impl ui::menu::Item for JumpMeta { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { let path = self .path .as_deref() @@ -2408,7 +2550,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())) }) @@ -2416,13 +2557,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))); @@ -2431,17 +2574,15 @@ fn jumplist_picker(cx: &mut Context) { impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; - fn label(&self, keymap: &Self::Data) -> Spans { + fn format(&self, keymap: &Self::Data) -> Row { let fmt_binding = |bindings: &Vec>| -> String { bindings.iter().fold(String::new(), |mut acc, bind| { if !acc.is_empty() { - acc.push_str(", "); + acc.push(' '); + } + for key in bind { + acc.push_str(&key.key_sequence_format()); } - bind.iter().fold(false, |needs_plus, key| { - write!(&mut acc, "{}{}", if needs_plus { "+" } else { "" }, key) - .expect("Writing to a string can only fail on an Out-Of-Memory error"); - true - }); acc }) }; @@ -2484,7 +2625,22 @@ pub fn command_palette(cx: &mut Context) { on_next_key_callback: None, jobs: cx.jobs, }; + let focus = view!(ctx.editor).id; + command.execute(&mut ctx); + + if ctx.editor.tree.contains(focus) { + let config = ctx.editor.config(); + let mode = ctx.editor.mode(); + let view = view_mut!(ctx.editor, focus); + let doc = doc_mut!(ctx.editor, &view.doc); + + view.ensure_cursor_in_view(doc, config.scrolloff); + + if mode != Mode::Insert { + doc.append_changes_to_history(view); + } + } }); compositor.push(Box::new(overlayed(picker))); }, @@ -2547,8 +2703,8 @@ async fn make_format_callback( if let Ok(format) = format { if doc.version() == doc_version { - apply_transaction(&format, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&format, view.id); + doc.append_changes_to_history(view); doc.detect_indent_and_line_ending(); view.ensure_cursor_in_view(doc, scrolloff); } else { @@ -2640,7 +2796,7 @@ fn open(cx: &mut Context, open: Open) { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } // o inserts a new line after each line with a selection @@ -2654,62 +2810,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); - - // if leaving append mode, move cursor back by 1 - if doc.restore_cursor { - let text = doc.text().slice(..); - let selection = doc.selection(view.id).clone().transform(|range| { - Range::new( - range.from(), - graphemes::prev_grapheme_boundary(text, range.to()), - ) - }); - - doc.set_selection(view.id, selection); - doc.restore_cursor = false; - } -} - -fn try_restore_indent(doc: &mut Document, view: &mut View) { - use helix_core::chars::char_is_whitespace; - use helix_core::Operation; - - fn inserted_a_new_blank_line(changes: &[Operation], pos: usize, line_end_pos: usize) -> bool { - if let [Operation::Retain(move_pos), Operation::Insert(ref inserted_str), Operation::Retain(_)] = - changes - { - move_pos + inserted_str.len() == pos - && inserted_str.starts_with('\n') - && inserted_str.chars().skip(1).all(char_is_whitespace) - && pos == line_end_pos // ensure no characters exists after current position - } else { - false - } - } - - let doc_changes = doc.changes().changes(); - let text = doc.text().slice(..); - let range = doc.selection(view.id).primary(); - let pos = range.cursor(text); - let line_end_pos = line_end_char_index(&text, range.cursor_line(text)); - - if inserted_a_new_blank_line(doc_changes, pos, line_end_pos) { - // Removes tailing whitespaces. - let transaction = - Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { - let line_start_pos = text.line_to_char(range.cursor_line(text)); - (line_start_pos, pos, None) - }); - apply_transaction(&transaction, doc, view); - } + cx.editor.enter_normal_mode(); } // Store a jump on the jumplist. @@ -2725,15 +2826,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() @@ -2746,14 +2847,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() @@ -2835,26 +2936,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) @@ -2867,17 +2969,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) @@ -2891,12 +2992,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) + } + } +} - goto_pos(editor, pos); +fn goto_next_change(cx: &mut Context) { + goto_next_change_impl(cx, Direction::Forward) +} + +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 { @@ -2937,6 +3134,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); @@ -3022,7 +3224,7 @@ pub mod insert { let (view, doc) = current!(cx.editor); if let Some(t) = transaction { - apply_transaction(&t, doc, view); + doc.apply(&t, view.id); } // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) @@ -3044,7 +3246,7 @@ pub mod insert { &doc.selection(view.id).clone().cursors(doc.text().slice(..)), indent, ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } pub fn insert_newline(cx: &mut Context) { @@ -3069,40 +3271,58 @@ 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)) + .map_or(false, |pair| pair.open == prev && pair.close == curr); + + 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 { @@ -3123,15 +3343,15 @@ 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); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } pub fn delete_char_backward(cx: &mut Context) { @@ -3226,7 +3446,7 @@ pub mod insert { } }); let (view, doc) = current!(cx.editor); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3244,7 +3464,7 @@ pub mod insert { None, ) }); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3330,7 +3550,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 @@ -3450,7 +3670,14 @@ enum Paste { Cursor, } -fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Paste, count: usize) { +fn paste_impl( + values: &[String], + doc: &mut Document, + view: &mut View, + action: Paste, + count: usize, + mode: Mode, +) { if values.is_empty() { return; } @@ -3482,7 +3709,7 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Pa let mut offset = 0; let mut ranges = SmallVec::with_capacity(selection.len()); - let transaction = Transaction::change_by_selection(text, selection, |range| { + let mut transaction = Transaction::change_by_selection(text, selection, |range| { let pos = match (action, linewise) { // paste linewise before (Paste::Before, true) => text.line_to_char(text.char_to_line(range.from())), @@ -3514,9 +3741,12 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Pa (pos, pos, value) }); - let transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + if mode == Mode::Normal { + transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + } - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); } pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { @@ -3526,7 +3756,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( @@ -3538,7 +3768,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")), @@ -3608,7 +3838,7 @@ fn replace_with_yanked(cx: &mut Context) { } }); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); exit_select_mode(cx); } } @@ -3632,8 +3862,8 @@ fn replace_selections_with_clipboard_impl( ) }); - apply_transaction(&transaction, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); } Err(e) => return Err(e.context("Couldn't get system clipboard contents")), } @@ -3657,7 +3887,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); } } @@ -3704,7 +3934,7 @@ fn indent(cx: &mut Context) { Some((pos, pos, Some(indent.clone()))) }), ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn unindent(cx: &mut Context) { @@ -3743,7 +3973,7 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn format_selections(cx: &mut Context) { @@ -3776,15 +4006,21 @@ fn format_selections(cx: &mut Context) { let range = ranges[0]; - let edits = tokio::task::block_in_place(|| { - helix_lsp::block_on(language_server.text_document_range_formatting( - doc.identifier(), - range, - lsp::FormattingOptions::default(), - None, - )) - }) - .unwrap_or_default(); + let request = match language_server.text_document_range_formatting( + doc.identifier(), + range, + lsp::FormattingOptions::default(), + None, + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support range formatting"); + return; + } + }; + + let edits = tokio::task::block_in_place(|| helix_lsp::block_on(request)).unwrap_or_default(); let transaction = helix_lsp::util::generate_transaction_from_edits( doc.text(), @@ -3792,10 +4028,10 @@ fn format_selections(cx: &mut Context) { language_server.offset_encoding(), ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } -fn join_selections_inner(cx: &mut Context, select_space: bool) { +fn join_selections_impl(cx: &mut Context, select_space: bool) { use movement::skip_while; let (view, doc) = current!(cx.editor); let text = doc.text(); @@ -3824,6 +4060,11 @@ fn join_selections_inner(cx: &mut Context, select_space: bool) { } } + // nothing to do, bail out early to avoid crashes later + if changes.is_empty() { + return; + } + changes.sort_unstable_by_key(|(from, _to, _text)| *from); changes.dedup(); @@ -3846,7 +4087,7 @@ fn join_selections_inner(cx: &mut Context, select_space: bool) { Transaction::change(doc.text(), changes.into_iter()) }; - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { @@ -3874,11 +4115,11 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { } fn join_selections(cx: &mut Context) { - join_selections_inner(cx, false) + join_selections_impl(cx, false) } fn join_selections_space(cx: &mut Context) { - join_selections_inner(cx, true) + join_selections_impl(cx, true) } fn keep_selections(cx: &mut Context) { @@ -3928,7 +4169,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; @@ -3940,7 +4184,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, @@ -3950,7 +4193,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 { @@ -3960,15 +4203,6 @@ pub fn completion(cx: &mut Context) { None => Vec::new(), }; - if !prefix.is_empty() { - items.retain(|item| { - item.filter_text - .as_ref() - .unwrap_or(&item.label) - .starts_with(&prefix) - }); - } - if items.is_empty() { // editor.set_error("No completion available"); return; @@ -3996,7 +4230,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); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); exit_select_mode(cx); } @@ -4052,7 +4286,7 @@ fn rotate_selection_contents(cx: &mut Context, direction: Direction) { .map(|(range, fragment)| (range.from(), range.to(), Some(fragment))), ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } fn rotate_selection_contents_forward(cx: &mut Context) { @@ -4162,6 +4396,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; @@ -4175,12 +4410,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(); @@ -4194,7 +4430,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); }; } @@ -4208,6 +4444,10 @@ fn rotate_view(cx: &mut Context) { cx.editor.focus_next() } +fn rotate_view_reverse(cx: &mut Context) { + cx.editor.focus_prev() +} + fn jump_view_right(cx: &mut Context) { cx.editor.focus_direction(tree::Direction::Right) } @@ -4341,11 +4581,19 @@ fn align_view_bottom(cx: &mut Context) { fn align_view_middle(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - let pos = doc.selection(view.id).primary().cursor(text); - let pos = coords_at_pos(text, pos); + let inner_width = view.inner_width(doc); + let text_fmt = doc.text_format(inner_width, None); + // there is no horizontal position when softwrap is enabled + if text_fmt.soft_wrap { + return; + } + let doc_text = doc.text().slice(..); + let annotations = view.text_annotations(doc, None); + let pos = doc.selection(view.id).primary().cursor(doc_text); + let pos = + visual_offset_from_block(doc_text, view.offset.anchor, pos, &text_fmt, &annotations).0; - view.offset.col = pos + view.offset.horizontal_offset = pos .col .saturating_sub((view.inner_area(doc).width as usize) / 2); } @@ -4473,19 +4721,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) @@ -4509,11 +4779,11 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { ("w", "Word"), ("W", "WORD"), ("p", "Paragraph"), - ("c", "Class (tree-sitter)"), + ("t", "Type definition (tree-sitter)"), ("f", "Function (tree-sitter)"), ("a", "Argument/parameter (tree-sitter)"), - ("o", "Comment (tree-sitter)"), - ("t", "Test (tree-sitter)"), + ("c", "Comment (tree-sitter)"), + ("T", "Test (tree-sitter)"), ("m", "Closest surrounding pair to cursor"), (" ", "... or any character acting as a pair"), ]; @@ -4523,26 +4793,46 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { fn surround_add(cx: &mut Context) { cx.on_next_key(move |cx, event| { - let ch = match event.char() { - Some(ch) => ch, + let (view, doc) = current!(cx.editor); + // surround_len is the number of new characters being added. + let (open, close, surround_len) = match event.char() { + Some(ch) => { + let (o, c) = surround::get_pair(ch); + let mut open = Tendril::new(); + open.push(o); + let mut close = Tendril::new(); + close.push(c); + (open, close, 2) + } + None if event.code == KeyCode::Enter => ( + doc.line_ending.as_str().into(), + doc.line_ending.as_str().into(), + 2 * doc.line_ending.len_chars(), + ), None => return, }; - let (view, doc) = current!(cx.editor); - let selection = doc.selection(view.id); - let (open, close) = surround::get_pair(ch); + let selection = doc.selection(view.id); let mut changes = Vec::with_capacity(selection.len() * 2); + let mut ranges = SmallVec::with_capacity(selection.len()); + let mut offs = 0; + for range in selection.iter() { - let mut o = Tendril::new(); - o.push(open); - let mut c = Tendril::new(); - c.push(close); - changes.push((range.from(), range.from(), Some(o))); - changes.push((range.to(), range.to(), Some(c))); + changes.push((range.from(), range.from(), Some(open.clone()))); + changes.push((range.to(), range.to(), Some(close.clone()))); + + ranges.push( + Range::new(offs + range.from(), offs + range.to() + surround_len) + .with_direction(range.direction()), + ); + + offs += surround_len; } - let transaction = Transaction::change(doc.text(), changes.into_iter()); - apply_transaction(&transaction, doc, view); + let transaction = Transaction::change(doc.text(), changes.into_iter()) + .with_selection(Selection::new(ranges, selection.primary_index())); + doc.apply(&transaction, view.id); + exit_select_mode(cx); }) } @@ -4581,7 +4871,8 @@ fn surround_replace(cx: &mut Context) { (pos, pos + 1, Some(t)) }), ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); + exit_select_mode(cx); }); }) } @@ -4608,7 +4899,8 @@ fn surround_delete(cx: &mut Context) { let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); + exit_select_mode(cx); }) } @@ -4773,13 +5065,24 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { let mut ranges = SmallVec::with_capacity(selection.len()); let text = doc.text().slice(..); + let mut shell_output: Option = None; + let mut offset = 0isize; for range in selection.ranges() { - let fragment = range.slice(text); - let (output, success) = match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { - Ok(result) => result, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; + let (output, success) = if let Some(output) = shell_output.as_ref() { + (output.clone(), true) + } else { + let fragment = range.slice(text); + match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { + Ok(result) => { + if !pipe { + shell_output = Some(result.0.clone()); + } + result + } + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } } }; @@ -4788,21 +5091,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), }; - ranges.push(Range::new(to, to + output.chars().count())); + + // These `usize`s cannot underflow because selection ranges cannot overlap. + // Once the MSRV is 1.66.0 (mixed_integer_ops is stabilized), we can use checked + // arithmetic to assert this. + let anchor = (to as isize + offset - deleted_len as isize) as usize; + let new_range = Range::new(anchor, anchor + output_len).with_direction(range.direction()); + ranges.push(new_range); + offset = offset + output_len as isize - deleted_len as isize; + changes.push((from, to, Some(output))); } if behavior != &ShellBehavior::Ignore { let transaction = Transaction::change(doc.text(), changes.into_iter()) .with_selection(Selection::new(ranges, selection.primary_index())); - apply_transaction(&transaction, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); } // after replace cursor may be out of bounds, do this to @@ -4864,64 +5177,32 @@ fn add_newline_impl(cx: &mut Context, open: Open) { }); let transaction = Transaction::change(text, changes); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } enum IncrementDirection { Increase, Decrease, } -/// Increment object under cursor by count. + +/// Increment objects within selections by count. fn increment(cx: &mut Context) { increment_impl(cx, IncrementDirection::Increase); } -/// Decrement object under cursor by count. +/// Decrement objects within selections by count. fn decrement(cx: &mut Context) { increment_impl(cx, IncrementDirection::Decrease); } -/// This function differs from find_next_char_impl in that it stops searching at the newline, but also -/// starts searching at the current character, instead of the next. -/// It does not want to start at the next character because this function is used for incrementing -/// number and we don't want to move forward if we're already on a digit. -fn find_next_char_until_newline( - text: RopeSlice, - char_matcher: M, - pos: usize, - _count: usize, - _inclusive: bool, -) -> Option { - // Since we send the current line to find_nth_next instead of the whole text, we need to adjust - // the position we send to this function so that it's relative to that line and its returned - // position since it's expected this function returns a global position. - let line_index = text.char_to_line(pos); - let pos_delta = text.line_to_char(line_index); - let pos = pos - pos_delta; - search::find_nth_next(text.line(line_index), char_matcher, pos, 1).map(|pos| pos + pos_delta) -} - -/// Decrement object under cursor by `amount`. +/// Increment objects within selections by `amount`. +/// A negative `amount` will decrement objects within selections. fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { - // TODO: when incrementing or decrementing a number that gets a new digit or lose one, the - // selection is updated improperly. - find_char_impl( - cx.editor, - &find_next_char_until_newline, - true, - true, - char::is_ascii_digit, - 1, - ); - - // Increase by 1 if `IncrementDirection` is `Increase` - // Decrease by 1 if `IncrementDirection` is `Decrease` let sign = match increment_direction { IncrementDirection::Increase => 1, IncrementDirection::Decrease => -1, }; let mut amount = sign * cx.count() as i64; - // If the register is `#` then increase or decrease the `amount` by 1 per element let increase_by = if cx.register == Some('#') { sign } else { 0 }; @@ -4929,52 +5210,41 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) { let selection = doc.selection(view.id); let text = doc.text().slice(..); - let changes: Vec<_> = selection - .ranges() - .iter() - .filter_map(|range| { - let incrementor: Box = - if let Some(incrementor) = DateTimeIncrementor::from_range(text, *range) { - Box::new(incrementor) - } else if let Some(incrementor) = NumberIncrementor::from_range(text, *range) { - Box::new(incrementor) - } else { - return None; - }; - - let (range, new_text) = incrementor.increment(amount); + let mut new_selection_ranges = SmallVec::new(); + let mut cumulative_length_diff: i128 = 0; + let mut changes = vec![]; - amount += increase_by; + for range in selection { + let selected_text: Cow = range.fragment(text); + let new_from = ((range.from() as i128) + cumulative_length_diff) as usize; + let incremented = [increment::integer, increment::date_time] + .iter() + .find_map(|incrementor| incrementor(selected_text.as_ref(), amount)); - Some((range.from(), range.to(), Some(new_text))) - }) - .collect(); + amount += increase_by; - // Overlapping changes in a transaction will panic, so we need to find and remove them. - // For example, if there are cursors on each of the year, month, and day of `2021-11-29`, - // incrementing will give overlapping changes, with each change incrementing a different part of - // the date. Since these conflict with each other we remove these changes from the transaction - // so nothing happens. - let mut overlapping_indexes = HashSet::new(); - for (i, changes) in changes.windows(2).enumerate() { - if changes[0].1 > changes[1].0 { - overlapping_indexes.insert(i); - overlapping_indexes.insert(i + 1); + match incremented { + None => { + let new_range = Range::new( + new_from, + (range.to() as i128 + cumulative_length_diff) as usize, + ); + new_selection_ranges.push(new_range); + } + Some(new_text) => { + let new_range = Range::new(new_from, new_from + new_text.len()); + cumulative_length_diff += new_text.len() as i128 - selected_text.len() as i128; + new_selection_ranges.push(new_range); + changes.push((range.from(), range.to(), Some(new_text.into()))); + } } } - let changes = changes.into_iter().enumerate().filter_map(|(i, change)| { - if overlapping_indexes.contains(&i) { - None - } else { - Some(change) - } - }); - if changes.clone().count() > 0 { - let transaction = Transaction::change(doc.text(), changes); - let transaction = transaction.with_selection(selection.clone()); - - apply_transaction(&transaction, doc, view); + if !changes.is_empty() { + let new_selection = Selection::new(new_selection_ranges, selection.primary_index()); + let transaction = Transaction::change(doc.text(), changes.into_iter()); + let transaction = transaction.with_selection(new_selection); + doc.apply(&transaction, view.id); } } @@ -4993,7 +5263,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 c27417e397b1..b3166e395d90 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -12,7 +12,7 @@ use helix_view::editor::Breakpoint; use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; -use tui::text::Spans; +use tui::{text::Spans, widgets::Row}; use std::collections::HashMap; use std::future::Future; @@ -25,7 +25,7 @@ use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select impl ui::menu::Item for StackFrame { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { self.name.as_str().into() // TODO: include thread_states in the label } } @@ -33,7 +33,7 @@ impl ui::menu::Item for StackFrame { impl ui::menu::Item for DebugTemplate { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { self.name.as_str().into() } } @@ -41,7 +41,7 @@ impl ui::menu::Item for DebugTemplate { impl ui::menu::Item for Thread { type Data = ThreadStates; - fn label(&self, thread_states: &Self::Data) -> Spans { + fn format(&self, thread_states: &Self::Data) -> Row { format!( "{} ({})", self.name, @@ -85,7 +85,7 @@ fn thread_picker( frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1), )); - Some((path, pos)) + Some((path.into(), pos)) }, ); compositor.push(Box::new(picker)); @@ -706,7 +706,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { .and_then(|source| source.path.clone()) .map(|path| { ( - path, + path.into(), Some(( frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1), diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index c149e62b40bb..d12aa436dd98 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,20 +1,25 @@ +use futures_util::FutureExt; use helix_lsp::{ block_on, lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString}, util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, OffsetEncoding, }; -use tui::text::{Span, Spans}; +use tui::{ + text::{Span, Spans}, + widgets::Row, +}; use super::{align_view, push_jump, Align, Context, Editor, Open}; use helix_core::{path, Selection}; -use helix_view::{apply_transaction, document::Mode, editor::Action, theme::Style}; +use helix_view::{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, }, }; @@ -44,7 +49,7 @@ impl ui::menu::Item for lsp::Location { /// Current working directory. type Data = PathBuf; - fn label(&self, cwdir: &Self::Data) -> Spans { + fn format(&self, cwdir: &Self::Data) -> Row { // 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 @@ -78,7 +83,7 @@ impl ui::menu::Item for lsp::SymbolInformation { /// Path to currently focussed document type Data = Option; - fn label(&self, current_doc_path: &Self::Data) -> Spans { + fn format(&self, current_doc_path: &Self::Data) -> Row { if current_doc_path.as_ref() == Some(&self.location.uri) { self.name.as_str().into() } else { @@ -108,7 +113,7 @@ struct PickerDiagnostic { impl ui::menu::Item for PickerDiagnostic { type Data = (DiagnosticStyles, DiagnosticsFormat); - fn label(&self, (styles, format): &Self::Data) -> Spans { + fn format(&self, (styles, format): &Self::Data) -> Row { let mut style = self .diag .severity @@ -147,6 +152,7 @@ impl ui::menu::Item for PickerDiagnostic { Span::styled(&self.diag.message, style), Span::styled(code, style), ]) + .into() } } @@ -156,7 +162,7 @@ fn location_to_file_location(location: &lsp::Location) -> FileLocation { location.range.start.line as usize, location.range.end.line as usize, )); - (path, line) + (path.into(), line) } // TODO: share with symbol picker(symbol.location) @@ -333,7 +339,14 @@ pub fn symbol_picker(cx: &mut Context) { let current_url = doc.url(); let offset_encoding = language_server.offset_encoding(); - let future = language_server.document_symbols(doc.identifier()); + let future = match language_server.document_symbols(doc.identifier()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support document symbols"); + return; + } + }; cx.callback( future, @@ -365,15 +378,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))) }, ) } @@ -418,7 +471,7 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) { impl ui::menu::Item for lsp::CodeActionOrCommand { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { match self { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), @@ -493,7 +546,7 @@ pub fn code_action(cx: &mut Context) { let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - let future = language_server.code_actions( + let future = match language_server.code_actions( doc.identifier(), range, // Filter and convert overlapping diagnostics @@ -509,7 +562,14 @@ pub fn code_action(cx: &mut Context) { .collect(), only: None, }, - ); + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support code actions"); + return; + } + }; cx.callback( future, @@ -598,7 +658,7 @@ 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); }, ) @@ -606,7 +666,7 @@ pub fn code_action(cx: &mut Context) { impl ui::menu::Item for lsp::Command { type Data = (); - fn label(&self, _data: &Self::Data) -> Spans { + fn format(&self, _data: &Self::Data) -> Row { self.title.as_str().into() } } @@ -617,9 +677,16 @@ pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { // the command is executed on the server and communicated back // to the client asynchronously using workspace edits - let command_future = language_server.command(cmd); + let future = match language_server.command(cmd) { + Some(future) => future, + None => { + editor.set_error("Language server does not support executing commands"); + return; + } + }; + tokio::spawn(async move { - let res = command_future.await; + let res = future.await; if let Err(e) = res { log::error!("execute LSP command: {}", e); @@ -678,7 +745,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) } } } @@ -732,8 +799,9 @@ pub fn apply_workspace_edit( text_edits, offset_encoding, ); - apply_transaction(&transaction, doc, view_mut!(editor, view_id)); - doc.append_changes_to_history(view_id); + let view = view_mut!(editor, view_id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); }; if let Some(ref changes) = workspace_edit.changes { @@ -846,6 +914,31 @@ fn to_locations(definitions: Option) -> Vec future, + None => { + cx.editor + .set_error("Language server does not support goto-declaration"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + pub fn goto_definition(cx: &mut Context) { let (view, doc) = current!(cx.editor); let language_server = language_server!(cx.editor, doc); @@ -853,7 +946,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, @@ -871,7 +971,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, @@ -889,7 +996,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, @@ -907,7 +1021,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, @@ -950,7 +1071,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( @@ -1051,7 +1178,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, @@ -1121,8 +1255,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()), } @@ -1137,7 +1279,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 c6810f05390f..1fd11b65de5e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -4,10 +4,8 @@ use crate::job::Job; use super::*; -use helix_view::{ - apply_transaction, - editor::{Action, CloseError, ConfigEvent}, -}; +use helix_core::encoding; +use helix_view::editor::{Action, CloseError, ConfigEvent}; use ui::completers::{self, Completer}; #[derive(Clone)] @@ -65,12 +63,29 @@ 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); + let path = helix_core::path::expand_tilde(&path); + // 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(()) } @@ -463,8 +478,8 @@ fn set_line_ending( } }), ); - apply_transaction(&transaction, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); Ok(()) } @@ -777,7 +792,7 @@ fn theme( .editor .theme_loader .load(theme_name) - .with_context(|| "Theme does not exist")?; + .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"); } @@ -899,6 +914,7 @@ fn replace_selections_with_clipboard_impl( cx: &mut compositor::Context, clipboard_type: ClipboardType, ) -> anyhow::Result<()> { + let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); match cx.editor.clipboard_provider.get_contents(clipboard_type) { @@ -908,8 +924,9 @@ fn replace_selections_with_clipboard_impl( (range.from(), range.to(), Some(contents.as_str().into())) }); - apply_transaction(&transaction, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); + view.ensure_cursor_in_view(doc, scrolloff); Ok(()) } Err(e) => Err(e.context("Couldn't get system clipboard contents")), @@ -1017,6 +1034,131 @@ fn set_encoding( } } +/// Shows info about the character under the primary cursor. +fn get_character_info( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + + let grapheme_start = doc.selection(view.id).primary().cursor(text); + let grapheme_end = graphemes::next_grapheme_boundary(text, grapheme_start); + + if grapheme_start == grapheme_end { + return Ok(()); + } + + let grapheme = text.slice(grapheme_start..grapheme_end).to_string(); + let encoding = doc.encoding(); + + let printable = grapheme.chars().fold(String::new(), |mut s, c| { + match c { + '\0' => s.push_str("\\0"), + '\t' => s.push_str("\\t"), + '\n' => s.push_str("\\n"), + '\r' => s.push_str("\\r"), + _ => s.push(c), + } + + s + }); + + // Convert to Unicode codepoints if in UTF-8 + let unicode = if encoding == encoding::UTF_8 { + let mut unicode = " (".to_owned(); + + for (i, char) in grapheme.chars().enumerate() { + if i != 0 { + unicode.push(' '); + } + + unicode.push_str("U+"); + + let codepoint: u32 = if char.is_ascii() { + char.into() + } else { + // Not ascii means it will be multi-byte, so strip out the extra + // bits that encode the length & mark continuation bytes + + let s = String::from(char); + let bytes = s.as_bytes(); + + // First byte starts with 2-4 ones then a zero, so strip those off + let first = bytes[0]; + let codepoint = first & (0xFF >> (first.leading_ones() + 1)); + let mut codepoint = u32::from(codepoint); + + // Following bytes start with 10 + for byte in bytes.iter().skip(1) { + codepoint <<= 6; + codepoint += u32::from(*byte) & 0x3F; + } + + codepoint + }; + + unicode.push_str(&format!("{codepoint:0>4x}")); + } + + unicode.push(')'); + unicode + } else { + String::new() + }; + + // Give the decimal value for ascii characters + let dec = if encoding.is_ascii_compatible() && grapheme.len() == 1 { + format!(" Dec {}", grapheme.as_bytes()[0]) + } else { + String::new() + }; + + let hex = { + let mut encoder = encoding.new_encoder(); + let max_encoded_len = encoder + .max_buffer_length_from_utf8_without_replacement(grapheme.len()) + .unwrap(); + let mut bytes = Vec::with_capacity(max_encoded_len); + let mut current_byte = 0; + let mut hex = String::new(); + + for (i, char) in grapheme.chars().enumerate() { + if i != 0 { + hex.push_str(" +"); + } + + let (result, _input_bytes_read) = encoder.encode_from_utf8_to_vec_without_replacement( + &char.to_string(), + &mut bytes, + true, + ); + + if let encoding::EncoderResult::Unmappable(char) = result { + bail!("{char:?} cannot be mapped to {}", encoding.name()); + } + + for byte in &bytes[current_byte..] { + hex.push_str(&format!(" {byte:0>2x}")); + } + + current_byte = bytes.len(); + } + + hex + }; + + cx.editor + .set_status(format!("\"{printable}\"{unicode}{dec} Hex{hex}")); + + Ok(()) +} + /// Reload the [`Document`] from its source file. fn reload( cx: &mut compositor::Context, @@ -1028,10 +1170,62 @@ 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).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. @@ -1504,6 +1698,7 @@ fn sort_impl( _args: &[Cow], reverse: bool, ) -> anyhow::Result<()> { + let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -1527,8 +1722,9 @@ fn sort_impl( .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), ); - apply_transaction(&transaction, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); + view.ensure_cursor_in_view(doc, scrolloff); Ok(()) } @@ -1571,8 +1767,8 @@ fn reflow( (range.from(), range.to(), Some(reflowed_text)) }); - apply_transaction(&transaction, doc, view); - doc.append_changes_to_history(view.id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); view.ensure_cursor_in_view(doc, scrolloff); Ok(()) @@ -1689,13 +1885,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(()) } @@ -1711,7 +1924,7 @@ fn run_shell_command( let shell = &cx.editor.config().shell; let (output, success) = shell_impl(shell, &args.join(" "), None)?; if success { - cx.editor.set_status("Command succeed"); + cx.editor.set_status("Command succeeded"); } else { cx.editor.set_error("Command failed"); } @@ -2044,6 +2257,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: set_encoding, completer: None, }, + TypableCommand { + name: "character-info", + aliases: &["char"], + doc: "Get info about the character under the primary cursor.", + fun: get_character_info, + completer: None, + }, TypableCommand { name: "reload", aliases: &[], @@ -2051,6 +2271,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: reload, completer: None, }, + TypableCommand { + name: "reload-all", + aliases: &[], + doc: "Discard changes and reload all documents from the source files.", + fun: reload_all, + completer: None, + }, TypableCommand { name: "update", aliases: &[], @@ -2233,12 +2460,19 @@ 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"], doc: "Run a shell command", fun: run_shell_command, - completer: Some(completers::directory), + completer: Some(completers::filename), }, ]; @@ -2253,7 +2487,10 @@ pub static TYPABLE_COMMAND_MAP: Lazy = Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - let parts = shellwords::shellwords(input); - let ends_with_whitespace = shellwords::ends_with_whitespace(input); + let shellwords = Shellwords::from(input); + let words = shellwords.words(); - if parts.is_empty() || (parts.len() == 1 && !ends_with_whitespace) { + 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() @@ -2283,19 +2520,20 @@ pub(super) fn command_mode(cx: &mut Context) { } else { // Otherwise, use the command's completer and the last shellword // as completion input. - let part = if parts.len() == 1 { - &Cow::Borrowed("") + let (part, part_len) = if words.len() == 1 || shellwords.ends_with_whitespace() { + (&Cow::Borrowed(""), 0) } else { - parts.last().unwrap() + ( + words.last().unwrap(), + shellwords.parts().last().unwrap().len(), + ) }; if let Some(typed::TypableCommand { completer: Some(completer), .. - }) = typed::TYPABLE_COMMAND_MAP.get(&parts[0] as &str) + }) = typed::TYPABLE_COMMAND_MAP.get(&words[0] as &str) { - let part_len = shellwords::escape(part.clone()).len(); - completer(editor, part) .into_iter() .map(|(range, file)| { @@ -2328,7 +2566,8 @@ pub(super) fn command_mode(cx: &mut Context) { // Handle typable commands if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { - let args = shellwords::shellwords(input); + let shellwords = Shellwords::from(input); + let args = shellwords.words(); if let Err(e) = (cmd.fun)(cx, &args[1..], event) { cx.editor.set_error(format!("{}", e)); diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 9dad36209c62..2e4a2e20e9f4 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -1,4 +1,4 @@ -// Each component declares it's own size constraints and gets fitted based on it's parent. +// Each component declares its own size constraints and gets fitted based on its parent. // Q: how does this work with popups? // cursive does compositor.screen_mut().add_layer_at(pos::absolute(x, y), ) use helix_core::Position; @@ -97,6 +97,7 @@ impl Compositor { self.area = area; } + /// Add a layer to be rendered in front of all existing layers. pub fn push(&mut self, mut layer: Box) { let size = self.size(); // trigger required_size on init @@ -136,7 +137,8 @@ impl Compositor { let mut consumed = false; // propagate events through the layers until we either find a layer that consumes it or we - // run out of layers (event bubbling) + // run out of layers (event bubbling), starting at the front layer and then moving to the + // background. for layer in self.layers.iter_mut().rev() { match layer.handle_event(event, cx) { EventResult::Consumed(Some(callback)) => { diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index ac9f06fc6144..6558fe19fb4c 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/keymap.rs b/helix-term/src/keymap.rs index 088b3b6d2083..e94a5f66b447 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -184,7 +184,7 @@ impl<'de> serde::de::Visitor<'de> for KeyTrieVisitor { S: serde::de::SeqAccess<'de>, { let mut commands = Vec::new(); - while let Some(command) = seq.next_element::<&str>()? { + while let Some(command) = seq.next_element::()? { commands.push( command .parse::() @@ -390,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()) } @@ -600,4 +600,43 @@ mod tests { "Mismatch" ) } + + #[test] + fn escaped_keymap() { + use crate::commands::MappableCommand; + use helix_view::input::{KeyCode, KeyEvent, KeyModifiers}; + + let keys = r#" +"+" = [ + "select_all", + ":pipe sed -E 's/\\s+$//g'", +] + "#; + + let key = KeyEvent { + code: KeyCode::Char('+'), + modifiers: KeyModifiers::NONE, + }; + + let expectation = Keymap::new(KeyTrie::Node(KeyTrieNode::new( + "", + hashmap! { + key => KeyTrie::Sequence(vec!{ + MappableCommand::select_all, + MappableCommand::Typable { + name: "pipe".to_string(), + args: vec!{ + "sed".to_string(), + "-E".to_string(), + "'s/\\s+$//g'".to_string() + }, + doc: "".to_string(), + }, + }) + }, + vec![key], + ))); + + assert_eq!(toml::from_str(keys), Ok(expectation)); + } } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 118764d97585..01184f80edcd 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -7,8 +7,8 @@ use helix_core::hashmap; pub fn default() -> HashMap { let normal = keymap!({ "Normal mode" "h" | "left" => move_char_left, - "j" | "down" => move_line_down, - "k" | "up" => move_line_up, + "j" | "down" => move_visual_line_down, + "k" | "up" => move_visual_line_up, "l" | "right" => move_char_right, "t" => find_till_char, @@ -44,6 +44,7 @@ pub fn default() -> HashMap { "l" => goto_line_end, "s" => goto_first_nonwhitespace, "d" => goto_definition, + "D" => goto_declaration, "y" => goto_type_definition, "r" => goto_reference, "i" => goto_implementation, @@ -54,6 +55,8 @@ pub fn default() -> HashMap { "m" => goto_last_modified_file, "n" => goto_next_buffer, "p" => goto_previous_buffer, + "k" => move_line_up, + "j" => move_line_down, "." => goto_last_modification, }, ":" => command_mode, @@ -76,6 +79,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 +104,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, }, @@ -198,7 +206,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, @@ -315,8 +323,8 @@ pub fn default() -> HashMap { let mut select = normal.clone(); select.merge_nodes(keymap!({ "Select mode" "h" | "left" => extend_char_left, - "j" | "down" => extend_line_down, - "k" | "up" => extend_line_up, + "j" | "down" => extend_visual_line_down, + "k" | "up" => extend_visual_line_up, "l" | "right" => extend_char_right, "w" => extend_next_word_start, @@ -339,6 +347,10 @@ pub fn default() -> HashMap { "esc" => exit_select_mode, "v" => normal_mode, + "g" => { "Goto" + "k" => extend_line_up, + "j" => extend_line_down, + }, })); let insert = keymap!({ "Insert mode" "esc" => normal_mode, @@ -356,8 +368,8 @@ pub fn default() -> HashMap { "C-j" | "ret" => insert_newline, "tab" => insert_tab, - "up" => move_line_up, - "down" => move_line_down, + "up" => move_visual_line_up, + "down" => move_visual_line_down, "left" => move_char_left, "right" => move_char_right, "pageup" => page_up, diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index a945b20dedaf..f0bc9129a139 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -10,6 +10,9 @@ pub mod health; pub mod job; pub mod keymap; pub mod ui; +use std::path::Path; + +use ignore::DirEntry; pub use keymap::macros::*; #[cfg(not(windows))] @@ -22,3 +25,25 @@ fn true_color() -> bool { fn true_color() -> bool { true } + +/// Function used for filtering dir entries in the various file pickers. +fn filter_picker_entry(entry: &DirEntry, root: &Path, dedup_symlinks: bool) -> bool { + // We always want to ignore the .git directory, otherwise if + // `ignore` is turned off, we end up with a lot of noise + // in our picker. + if entry.file_name() == ".git" { + return false; + } + + // We also ignore symlinks that point inside the current directory + // if `dedup_links` is enabled. + if dedup_symlinks && entry.path_is_symlink() { + return entry + .path() + .canonicalize() + .ok() + .map_or(false, |path| !path.starts_with(&root)); + } + + true +} diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 96b695c6fcde..aac5c5379f37 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; @@ -74,7 +75,7 @@ FLAGS: --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(), @@ -89,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); } diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 545b19d8968f..90e2fed04d78 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,7 +1,6 @@ use crate::compositor::{Component, Context, Event, EventResult}; -use helix_view::{apply_transaction, editor::CompleteAction}; +use helix_view::{editor::CompleteAction, ViewId}; use tui::buffer::Buffer as Surface; -use tui::text::Spans; use std::borrow::Cow; @@ -33,11 +32,7 @@ impl menu::Item for CompletionItem { .into() } - fn label(&self, _data: &Self::Data) -> Spans { - self.label.as_str().into() - } - - fn row(&self, _data: &Self::Data) -> menu::Row { + fn format(&self, _data: &Self::Data) -> menu::Row { menu::Row::new(vec![ menu::Cell::from(self.label.as_str()), menu::Cell::from(match self.kind { @@ -107,6 +102,7 @@ impl Completion { 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, @@ -121,9 +117,10 @@ impl Completion { } }; - 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 { @@ -132,10 +129,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 @@ -164,6 +174,7 @@ impl Completion { let transaction = item_to_transaction( doc, + view.id, item, offset_encoding, start_offset, @@ -172,7 +183,7 @@ impl Completion { // initialize a savepoint doc.savepoint(); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -185,13 +196,14 @@ impl Completion { let transaction = item_to_transaction( doc, + view.id, item, offset_encoding, start_offset, trigger_offset, ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -221,13 +233,13 @@ impl Completion { additional_edits.clone(), offset_encoding, // TODO: should probably transcode in Client ); - apply_transaction(&transaction, doc, view); + doc.apply(&transaction, view.id); } } } }; }); - let popup = Popup::new(Self::ID, menu); + let popup = Popup::new(Self::ID, menu).with_scrollbar(false); let mut completion = Self { popup, start_offset, @@ -245,21 +257,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 } } @@ -304,6 +308,12 @@ impl Completion { self.popup.contents().is_empty() } + fn replace_item(&mut self, old_item: lsp::CompletionItem, new_item: lsp::CompletionItem) { + self.popup.contents_mut().replace_option(old_item, new_item); + } + + /// Asynchronously requests that the currently selection completion item is + /// resolved through LSP `completionItem/resolve`. pub fn ensure_item_resolved(&mut self, cx: &mut commands::Context) -> bool { // > If computing full completion items is expensive, servers can additionally provide a // > handler for the completion item resolve request. ... @@ -313,16 +323,41 @@ impl Completion { // > 'completionItem/resolve' request is sent with the selected completion item as a parameter. // > The returned completion item should have the documentation property filled in. // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion - match self.popup.contents_mut().selection_mut() { - Some(item) if item.documentation.is_none() => { - let doc = doc!(cx.editor); - if let Some(resolved_item) = Self::resolve_completion_item(doc, item.clone()) { - *item = resolved_item; + let current_item = match self.popup.contents().selection() { + Some(item) if item.documentation.is_none() => item.clone(), + _ => return false, + }; + + let language_server = match doc!(cx.editor).language_server() { + Some(language_server) => language_server, + None => return false, + }; + + // This method should not block the compositor so we handle the response asynchronously. + let future = match language_server.resolve_completion_item(current_item.clone()) { + Some(future) => future, + None => return false, + }; + + cx.callback( + future, + move |_editor, compositor, response: Option| { + let resolved_item = match response { + Some(item) => item, + None => return, + }; + + if let Some(completion) = &mut compositor + .find::() + .unwrap() + .completion + { + completion.replace_item(current_item, resolved_item); } - true - } - _ => false, - } + }, + ); + + true } } @@ -356,8 +391,10 @@ impl Component for Completion { let language = doc.language_name().unwrap_or(""); let text = doc.text().slice(..); let cursor_pos = doc.selection(view.id).primary().cursor(text); - let coords = helix_core::visual_coords_at_pos(text, cursor_pos, doc.tab_width()); - let cursor_pos = (coords.row - view.offset.row) as u16; + let coords = view + .screen_coords_at_pos(doc, text, cursor_pos) + .expect("cursor must be in view"); + let cursor_pos = coords.row as u16; let mut markdown_doc = match &option.documentation { Some(lsp::Documentation::String(contents)) @@ -371,7 +408,7 @@ impl Component for Completion { "```{}\n{}\n```\n{}", language, option.detail.as_deref().unwrap_or_default(), - contents.clone() + contents ), cx.editor.syn_loader.clone(), ) @@ -381,15 +418,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/document.rs b/helix-term/src/ui/document.rs new file mode 100644 index 000000000000..56377d1ec455 --- /dev/null +++ b/helix-term/src/ui/document.rs @@ -0,0 +1,478 @@ +use std::cmp::min; + +use helix_core::doc_formatter::{DocumentFormatter, GraphemeSource, TextFormat}; +use helix_core::graphemes::Grapheme; +use helix_core::str_utils::char_to_byte_idx; +use helix_core::syntax::Highlight; +use helix_core::syntax::HighlightEvent; +use helix_core::text_annotations::TextAnnotations; +use helix_core::{visual_offset_from_block, Position, RopeSlice}; +use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; +use helix_view::graphics::Rect; +use helix_view::theme::Style; +use helix_view::view::ViewPosition; +use helix_view::Document; +use helix_view::Theme; +use tui::buffer::Buffer as Surface; + +pub trait LineDecoration { + fn render_background(&mut self, _renderer: &mut TextRenderer, _pos: LinePos) {} + fn render_foreground( + &mut self, + _renderer: &mut TextRenderer, + _pos: LinePos, + _end_char_idx: usize, + ) { + } +} + +impl LineDecoration for F { + fn render_background(&mut self, renderer: &mut TextRenderer, pos: LinePos) { + self(renderer, pos) + } +} + +/// A wrapper around a HighlightIterator +/// that merges the layered highlights to create the final text style +/// and yields the active text style and the char_idx where the active +/// style will have to be recomputed. +struct StyleIter<'a, H: Iterator> { + text_style: Style, + active_highlights: Vec, + highlight_iter: H, + theme: &'a Theme, +} + +impl> Iterator for StyleIter<'_, H> { + type Item = (Style, usize); + fn next(&mut self) -> Option<(Style, usize)> { + while let Some(event) = self.highlight_iter.next() { + match event { + HighlightEvent::HighlightStart(highlights) => { + self.active_highlights.push(highlights) + } + HighlightEvent::HighlightEnd => { + self.active_highlights.pop(); + } + HighlightEvent::Source { start, end } => { + if start == end { + continue; + } + let style = self + .active_highlights + .iter() + .fold(self.text_style, |acc, span| { + acc.patch(self.theme.highlight(span.0)) + }); + return Some((style, end)); + } + } + } + None + } +} + +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub struct LinePos { + /// Indicates whether the given visual line + /// is the first visual line of the given document line + pub first_visual_line: bool, + /// The line index of the document line that contains the given visual line + pub doc_line: usize, + /// Vertical offset from the top of the inner view area + pub visual_line: u16, + /// The first char index of this visual line. + /// Note that if the visual line is entirely filled by + /// a very long inline virtual text then this index will point + /// at the next (non-virtual) char after this visual line + pub start_char_idx: usize, +} + +pub type TranslatedPosition<'a> = (usize, Box); + +#[allow(clippy::too_many_arguments)] +pub fn render_document( + surface: &mut Surface, + viewport: Rect, + doc: &Document, + offset: ViewPosition, + doc_annotations: &TextAnnotations, + highlight_iter: impl Iterator, + theme: &Theme, + line_decoration: &mut [Box], + translated_positions: &mut [TranslatedPosition], +) { + let mut renderer = TextRenderer::new(surface, doc, theme, offset.horizontal_offset, viewport); + render_text( + &mut renderer, + doc.text().slice(..), + offset, + &doc.text_format(viewport.width, Some(theme)), + doc_annotations, + highlight_iter, + theme, + line_decoration, + translated_positions, + ) +} + +fn translate_positions( + char_pos: usize, + first_visisble_char_idx: usize, + translated_positions: &mut [TranslatedPosition], + text_fmt: &TextFormat, + renderer: &mut TextRenderer, + pos: Position, +) { + // check if any positions translated on the fly (like cursor) has been reached + for (char_idx, callback) in &mut *translated_positions { + if *char_idx < char_pos && *char_idx >= first_visisble_char_idx { + // by replacing the char_index with usize::MAX large number we ensure + // that the same position is only translated once + // text will never reach usize::MAX as rust memory allocations are limited + // to isize::MAX + *char_idx = usize::MAX; + + if text_fmt.soft_wrap { + callback(renderer, pos) + } else if pos.col >= renderer.col_offset + && pos.col - renderer.col_offset < renderer.viewport.width as usize + { + callback( + renderer, + Position { + row: pos.row, + col: pos.col - renderer.col_offset, + }, + ) + } + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn render_text<'t>( + renderer: &mut TextRenderer, + text: RopeSlice<'t>, + offset: ViewPosition, + text_fmt: &TextFormat, + text_annotations: &TextAnnotations, + highlight_iter: impl Iterator, + theme: &Theme, + line_decorations: &mut [Box], + translated_positions: &mut [TranslatedPosition], +) { + let ( + Position { + row: mut row_off, .. + }, + mut char_pos, + ) = visual_offset_from_block( + text, + offset.anchor, + offset.anchor, + text_fmt, + text_annotations, + ); + row_off += offset.vertical_offset; + assert_eq!(0, offset.vertical_offset); + + let (mut formatter, mut first_visible_char_idx) = + DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, offset.anchor); + let mut styles = StyleIter { + text_style: renderer.text_style, + active_highlights: Vec::with_capacity(64), + highlight_iter, + theme, + }; + + let mut last_line_pos = LinePos { + first_visual_line: false, + doc_line: usize::MAX, + visual_line: u16::MAX, + start_char_idx: usize::MAX, + }; + let mut is_in_indent_area = true; + let mut last_line_indent_level = 0; + let mut style_span = styles + .next() + .unwrap_or_else(|| (Style::default(), usize::MAX)); + + loop { + // formattter.line_pos returns to line index of the next grapheme + // so it must be called before formatter.next + let doc_line = formatter.line_pos(); + // TODO refactor with let .. else once MSRV reaches 1.65 + let (grapheme, mut pos) = if let Some(it) = formatter.next() { + it + } else { + let mut last_pos = formatter.visual_pos(); + if last_pos.row >= row_off { + last_pos.col -= 1; + last_pos.row -= row_off; + // check if any positions translated on the fly (like cursor) are at the EOF + translate_positions( + char_pos + 1, + first_visible_char_idx, + translated_positions, + text_fmt, + renderer, + last_pos, + ); + } + break; + }; + + // skip any graphemes on visual lines before the block start + if pos.row < row_off { + if char_pos >= style_span.1 { + // TODO refactor using let..else once MSRV reaches 1.65 + style_span = if let Some(style_span) = styles.next() { + style_span + } else { + break; + } + } + char_pos += grapheme.doc_chars(); + first_visible_char_idx = char_pos + 1; + continue; + } + pos.row -= row_off; + + // if the end of the viewport is reached stop rendering + if pos.row as u16 >= renderer.viewport.height { + break; + } + + // apply decorations before rendering a new line + if pos.row as u16 != last_line_pos.visual_line { + if pos.row > 0 { + renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); + is_in_indent_area = true; + for line_decoration in &mut *line_decorations { + line_decoration.render_foreground(renderer, last_line_pos, char_pos); + } + } + last_line_pos = LinePos { + first_visual_line: doc_line != last_line_pos.doc_line, + doc_line, + visual_line: pos.row as u16, + start_char_idx: char_pos, + }; + for line_decoration in &mut *line_decorations { + line_decoration.render_background(renderer, last_line_pos); + } + } + + // aquire the correct grapheme style + if char_pos >= style_span.1 { + // TODO refactor using let..else once MSRV reaches 1.65 + style_span = if let Some(style_span) = styles.next() { + style_span + } else { + (Style::default(), usize::MAX) + } + } + char_pos += grapheme.doc_chars(); + + // check if any positions translated on the fly (like cursor) has been reached + translate_positions( + char_pos, + first_visible_char_idx, + translated_positions, + text_fmt, + renderer, + pos, + ); + + let grapheme_style = if let GraphemeSource::VirtualText { highlight } = grapheme.source { + let style = renderer.text_style; + if let Some(highlight) = highlight { + style.patch(theme.highlight(highlight.0)) + } else { + style + } + } else { + style_span.0 + }; + + renderer.draw_grapheme( + grapheme.grapheme, + grapheme_style, + &mut last_line_indent_level, + &mut is_in_indent_area, + pos, + ); + } + + renderer.draw_indent_guides(last_line_indent_level, last_line_pos.visual_line); + for line_decoration in &mut *line_decorations { + line_decoration.render_foreground(renderer, last_line_pos, char_pos); + } +} + +#[derive(Debug)] +pub struct TextRenderer<'a> { + pub surface: &'a mut Surface, + pub text_style: Style, + pub whitespace_style: Style, + pub indent_guide_char: String, + pub indent_guide_style: Style, + pub newline: String, + pub nbsp: String, + pub space: String, + pub tab: String, + pub tab_width: u16, + pub starting_indent: usize, + pub draw_indent_guides: bool, + pub col_offset: usize, + pub viewport: Rect, +} + +impl<'a> TextRenderer<'a> { + pub fn new( + surface: &'a mut Surface, + doc: &Document, + theme: &Theme, + col_offset: usize, + viewport: Rect, + ) -> TextRenderer<'a> { + let editor_config = doc.config.load(); + let WhitespaceConfig { + render: ws_render, + characters: ws_chars, + } = &editor_config.whitespace; + + let tab_width = doc.tab_width(); + let tab = if ws_render.tab() == WhitespaceRenderValue::All { + std::iter::once(ws_chars.tab) + .chain(std::iter::repeat(ws_chars.tabpad).take(tab_width - 1)) + .collect() + } else { + " ".repeat(tab_width) + }; + let newline = if ws_render.newline() == WhitespaceRenderValue::All { + ws_chars.newline.into() + } else { + " ".to_owned() + }; + + let space = if ws_render.space() == WhitespaceRenderValue::All { + ws_chars.space.into() + } else { + " ".to_owned() + }; + let nbsp = if ws_render.nbsp() == WhitespaceRenderValue::All { + ws_chars.nbsp.into() + } else { + " ".to_owned() + }; + + let text_style = theme.get("ui.text"); + + TextRenderer { + surface, + indent_guide_char: editor_config.indent_guides.character.into(), + newline, + nbsp, + space, + tab_width: tab_width as u16, + tab, + whitespace_style: theme.get("ui.virtual.whitespace"), + starting_indent: (col_offset / tab_width) + + editor_config.indent_guides.skip_levels as usize, + indent_guide_style: text_style.patch( + theme + .try_get("ui.virtual.indent-guide") + .unwrap_or_else(|| theme.get("ui.virtual.whitespace")), + ), + text_style, + draw_indent_guides: editor_config.indent_guides.render, + viewport, + col_offset, + } + } + + /// Draws a single `grapheme` at the current render position with a specified `style`. + pub fn draw_grapheme( + &mut self, + grapheme: Grapheme, + mut style: Style, + last_indent_level: &mut usize, + is_in_indent_area: &mut bool, + position: Position, + ) { + let cut_off_start = self.col_offset.saturating_sub(position.col as usize); + let is_whitespace = grapheme.is_whitespace(); + + // TODO is it correct to apply the whitspace style to all unicode white spaces? + if is_whitespace { + style = style.patch(self.whitespace_style); + } + + let width = grapheme.width(); + let grapheme = match grapheme { + Grapheme::Tab { width } => { + let grapheme_tab_width = char_to_byte_idx(&self.tab, width as usize); + &self.tab[..grapheme_tab_width] + } + // TODO special rendering for other whitespaces? + Grapheme::Other { ref g } if g == " " => &self.space, + Grapheme::Other { ref g } if g == "\u{00A0}" => &self.nbsp, + Grapheme::Other { ref g } => &*g, + Grapheme::Newline => &self.newline, + }; + + let in_bounds = self.col_offset <= (position.col as usize) + && (position.col as usize) < self.viewport.width as usize + self.col_offset; + + if in_bounds { + self.surface.set_string( + self.viewport.x + (position.col - self.col_offset) as u16, + self.viewport.y + position.row as u16, + grapheme, + style, + ); + } else if cut_off_start != 0 && cut_off_start < width as usize { + // partially on screen + let rect = Rect::new( + self.viewport.x as u16, + self.viewport.y + position.row as u16, + (width - cut_off_start) as u16, + 1, + ); + self.surface.set_style(rect, style); + } + + if *is_in_indent_area && !is_whitespace { + *last_indent_level = position.col; + *is_in_indent_area = false; + } + } + + /// Overlay indentation guides ontop of a rendered line + /// The indentation level is computed in `draw_lines`. + /// Therefore this function must always be called afterwards. + pub fn draw_indent_guides(&mut self, indent_level: usize, row: u16) { + if !self.draw_indent_guides { + return; + } + + // 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 + self.col_offset + self.viewport.width as usize + (self.tab_width - 1) as usize, + ) / self.tab_width as usize; + + for i in self.starting_indent..end_indent { + let x = + (self.viewport.x as usize + (i * self.tab_width as usize) - self.col_offset) as u16; + let y = self.viewport.y + row; + debug_assert!(self.surface.in_bounds(x, y)); + self.surface + .set_string(x, y, &self.indent_guide_char, self.indent_guide_style); + } + } +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index f2a588e338f2..f297b44eb08b 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -4,7 +4,10 @@ use crate::{ job::{self, Callback}, key, keymap::{KeymapResult, Keymaps}, - ui::{Completion, ProgressSpinners}, + ui::{ + document::{render_document, LinePos, TextRenderer, TranslatedPosition}, + Completion, ProgressSpinners, + }, }; use helix_core::{ @@ -13,11 +16,11 @@ use helix_core::{ }, movement::Direction, syntax::{self, HighlightEvent}, + text_annotations::TextAnnotations, unicode::width::UnicodeWidthStr, - visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, + visual_offset_from_block, Position, Range, Selection, Transaction, }; use helix_view::{ - apply_transaction, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, @@ -25,12 +28,12 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{borrow::Cow, cmp::min, num::NonZeroUsize, path::PathBuf}; +use std::{num::NonZeroUsize, path::PathBuf, rc::Rc}; use tui::buffer::Buffer as Surface; -use super::lsp::SignatureHelp; use super::statusline; +use super::{document::LineDecoration, lsp::SignatureHelp}; pub struct EditorView { pub keymaps: Keymaps, @@ -82,6 +85,11 @@ impl EditorView { let inner = view.inner_area(doc); let area = view.area; let theme = &editor.theme; + let config = editor.config(); + + let text_annotations = view.text_annotations(doc, Some(theme)); + let mut line_decorations: Vec> = Vec::new(); + let mut translated_positions: Vec = Vec::new(); // DAP: Highlight current stack frame position let stack_frame = editor.debugger.as_ref().and_then(|debugger| { @@ -103,28 +111,40 @@ impl EditorView { == doc.path() { let line = frame.line - 1; // convert to 0-indexing - if line >= view.offset.row && line < view.offset.row + area.height as usize { - surface.set_style( - Rect::new( - area.x, - area.y + (line - view.offset.row) as u16, - area.width, - 1, - ), - theme.get("ui.highlight"), - ); - } + let style = theme.get("ui.highlight"); + let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + if pos.doc_line != line { + return; + } + renderer + .surface + .set_style(Rect::new(area.x, pos.visual_line, area.width, 1), style); + }; + + line_decorations.push(Box::new(line_decoration)); } } - if is_focused && editor.config().cursorline { - Self::highlight_cursorline(doc, view, surface, theme); + if is_focused && config.cursorline { + line_decorations.push(Self::cursorline_decorator(doc, view, theme)) } - if is_focused && editor.config().cursorcolumn { - Self::highlight_cursorcolumn(doc, view, surface, theme); + + if is_focused && config.cursorcolumn { + Self::highlight_cursorcolumn(doc, view, surface, theme, inner, &text_annotations); + } + + let mut highlights = + Self::doc_syntax_highlights(doc, view.offset.anchor, inner.height, theme); + let overlay_highlights = Self::overlay_syntax_highlights( + doc, + view.offset.anchor, + inner.height, + &text_annotations, + ); + if !overlay_highlights.is_empty() { + highlights = Box::new(syntax::merge(highlights, overlay_highlights)); } - 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. @@ -133,37 +153,63 @@ impl EditorView { } highlights = Box::new(syntax::merge(highlights, diagnostic)); } + let highlights: Box> = if is_focused { - Box::new(syntax::merge( + let highlights = syntax::merge( highlights, Self::doc_selection_highlights( editor.mode(), doc, view, theme, - &editor.config().cursor_shape, + &config.cursor_shape, ), - )) + ); + let focused_view_elements = Self::highlight_focused_view_elements(view, doc, theme); + if focused_view_elements.is_empty() { + Box::new(highlights) + } else { + Box::new(syntax::merge(highlights, focused_view_elements)) + } } else { Box::new(highlights) }; - Self::render_text_highlights( + Self::render_gutter( + editor, doc, - view.offset, - inner, - surface, + view, + view.area, theme, - highlights, - &editor.config(), + is_focused, + &mut line_decorations, ); - Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused); - Self::render_rulers(editor, doc, view, inner, surface, theme); if is_focused { - Self::render_focused_view_elements(view, doc, inner, theme, surface); + let cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + // set the cursor_cache to out of view in case the position is not found + editor.cursor_cache.set(Some(None)); + let update_cursor_cache = + |_: &mut TextRenderer, pos| editor.cursor_cache.set(Some(Some(pos))); + translated_positions.push((cursor, Box::new(update_cursor_cache))); } + render_document( + surface, + inner, + doc, + view.offset, + &text_annotations, + highlights, + theme, + &mut line_decorations, + &mut *translated_positions, + ); + Self::render_rulers(editor, doc, view, inner, surface, theme); + // if we're not at the edge of the screen, draw a right border if viewport.right() != view.area.right() { let x = area.right(); @@ -176,7 +222,7 @@ impl EditorView { } } - self.render_diagnostics(doc, view, inner, surface, theme); + Self::render_diagnostics(doc, view, inner, surface, theme); let statusline_area = view .area @@ -211,31 +257,53 @@ impl EditorView { .iter() // View might be horizontally scrolled, convert from absolute distance // from the 1st column to relative distance from left of viewport - .filter_map(|ruler| ruler.checked_sub(1 + view.offset.col as u16)) + .filter_map(|ruler| ruler.checked_sub(1 + view.offset.horizontal_offset as u16)) .filter(|ruler| ruler < &viewport.width) .map(|ruler| viewport.clip_left(ruler).with_width(1)) .for_each(|area| surface.set_style(area, ruler_theme)) } + pub fn overlay_syntax_highlights( + doc: &Document, + anchor: usize, + height: u16, + text_annotations: &TextAnnotations, + ) -> Vec<(usize, std::ops::Range)> { + let text = doc.text().slice(..); + let row = text.char_to_line(anchor.min(text.len_chars())); + + let range = { + // Calculate viewport byte ranges: + // Saturating subs to make it inclusive zero indexing. + let last_line = text.len_lines().saturating_sub(1); + let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); + let start = text.line_to_byte(row.min(last_line)); + let end = text.line_to_byte(last_visible_line + 1); + + start..end + }; + + text_annotations.collect_overlay_highlights(range) + } + /// Get syntax highlights for a document in a view represented by the first line /// and column (`offset`) and the last line. This is done instead of using a view /// directly to enable rendering syntax highlighted docs anywhere (eg. picker preview) pub fn doc_syntax_highlights<'doc>( doc: &'doc Document, - offset: Position, + anchor: usize, height: u16, _theme: &Theme, ) -> Box + 'doc> { let text = doc.text().slice(..); + let row = text.char_to_line(anchor.min(text.len_chars())); let range = { // 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 last_line = text.len_lines().saturating_sub(1); + let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); + let start = text.line_to_byte(row.min(last_line)); let end = text.line_to_byte(last_visible_line + 1); start..end @@ -280,11 +348,11 @@ impl EditorView { use helix_core::diagnostic::Severity; let get_scope_of = |scope| { theme - .find_scope_index(scope) + .find_scope_index_exact(scope) // get one of the themes below as fallback values - .or_else(|| theme.find_scope_index("diagnostic")) - .or_else(|| theme.find_scope_index("ui.cursor")) - .or_else(|| theme.find_scope_index("ui.selection")) + .or_else(|| theme.find_scope_index_exact("diagnostic")) + .or_else(|| theme.find_scope_index_exact("ui.cursor")) + .or_else(|| theme.find_scope_index_exact("ui.selection")) .expect( "at least one of the following scopes must be defined in the theme: `diagnostic`, `ui.cursor`, or `ui.selection`", ) @@ -347,25 +415,31 @@ impl EditorView { let cursor_is_block = cursorkind == CursorKind::Block; let selection_scope = theme - .find_scope_index("ui.selection") + .find_scope_index_exact("ui.selection") .expect("could not find `ui.selection` scope in the theme!"); + let primary_selection_scope = theme + .find_scope_index_exact("ui.selection.primary") + .unwrap_or(selection_scope); let base_cursor_scope = theme - .find_scope_index("ui.cursor") + .find_scope_index_exact("ui.cursor") .unwrap_or(selection_scope); + let base_primary_cursor_scope = theme + .find_scope_index("ui.cursor.primary") + .unwrap_or(base_cursor_scope); let cursor_scope = match mode { - Mode::Insert => theme.find_scope_index("ui.cursor.insert"), - Mode::Select => theme.find_scope_index("ui.cursor.select"), - Mode::Normal => Some(base_cursor_scope), + Mode::Insert => theme.find_scope_index_exact("ui.cursor.insert"), + Mode::Select => theme.find_scope_index_exact("ui.cursor.select"), + Mode::Normal => theme.find_scope_index_exact("ui.cursor.normal"), } .unwrap_or(base_cursor_scope); - let primary_cursor_scope = theme - .find_scope_index("ui.cursor.primary") - .unwrap_or(cursor_scope); - let primary_selection_scope = theme - .find_scope_index("ui.selection.primary") - .unwrap_or(selection_scope); + let primary_cursor_scope = match mode { + Mode::Insert => theme.find_scope_index_exact("ui.cursor.primary.insert"), + Mode::Select => theme.find_scope_index_exact("ui.cursor.primary.select"), + Mode::Normal => theme.find_scope_index_exact("ui.cursor.primary.normal"), + } + .unwrap_or(base_primary_cursor_scope); let mut spans: Vec<(usize, std::ops::Range)> = Vec::new(); for (i, range) in selection.iter().enumerate() { @@ -393,7 +467,14 @@ impl EditorView { if range.head > range.anchor { // Standard case. let cursor_start = prev_grapheme_boundary(text, range.head); - spans.push((selection_scope, range.anchor..cursor_start)); + // non block cursors look like they exclude the cursor + let selection_end = + if selection_is_primary && !cursor_is_block && mode != Mode::Insert { + range.head + } else { + cursor_start + }; + spans.push((selection_scope, range.anchor..selection_end)); if !selection_is_primary || cursor_is_block { spans.push((cursor_scope, cursor_start..range.head)); } @@ -403,255 +484,42 @@ impl EditorView { if !selection_is_primary || cursor_is_block { spans.push((cursor_scope, range.head..cursor_end)); } - spans.push((selection_scope, cursor_end..range.anchor)); + // non block cursors look like they exclude the cursor + let selection_start = if selection_is_primary + && !cursor_is_block + && !(mode == Mode::Insert && cursor_end == range.anchor) + { + range.head + } else { + cursor_end + }; + spans.push((selection_scope, selection_start..range.anchor)); } } spans } - pub fn render_text_highlights>( - doc: &Document, - offset: Position, - viewport: Rect, - surface: &mut Surface, - theme: &Theme, - highlights: H, - config: &helix_view::editor::Config, - ) { - let whitespace = &config.whitespace; - use helix_view::editor::WhitespaceRenderValue; - - // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch - // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). - let text = doc.text().slice(..); - - let characters = &whitespace.characters; - - let mut spans = Vec::new(); - let mut visual_x = 0usize; - let mut line = 0u16; - let tab_width = doc.tab_width(); - let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { - std::iter::once(characters.tab) - .chain(std::iter::repeat(characters.tabpad).take(tab_width - 1)) - .collect() - } else { - " ".repeat(tab_width) - }; - let space = characters.space.to_string(); - let nbsp = characters.nbsp.to_string(); - let newline = if whitespace.render.newline() == WhitespaceRenderValue::All { - characters.newline.to_string() - } else { - " ".to_string() - }; - let indent_guide_char = config.indent_guides.character.to_string(); - - let text_style = theme.get("ui.text"); - let whitespace_style = theme.get("ui.virtual.whitespace"); - - let mut is_in_indent_area = true; - let mut last_line_indent_level = 0; - - // use whitespace style as fallback for indent-guide - let indent_guide_style = text_style.patch( - theme - .try_get("ui.virtual.indent-guide") - .unwrap_or_else(|| theme.get("ui.virtual.whitespace")), - ); - - let draw_indent_guides = |indent_level, line, surface: &mut Surface| { - if !config.indent_guides.render { - return; - } - - 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); - } - }; - - 'outer: for event in highlights { - match event { - HighlightEvent::HighlightStart(span) => { - spans.push(span); - } - HighlightEvent::HighlightEnd => { - spans.pop(); - } - HighlightEvent::Source { start, end } => { - let is_trailing_cursor = text.len_chars() < end; - - // `unwrap_or_else` part is for off-the-end indices of - // the rope, to allow cursor highlighting at the end - // of the rope. - let text = text.get_slice(start..end).unwrap_or_else(|| " ".into()); - let style = spans - .iter() - .fold(text_style, |acc, span| acc.patch(theme.highlight(span.0))); - - let space = if whitespace.render.space() == WhitespaceRenderValue::All - && !is_trailing_cursor - { - &space - } else { - " " - }; - - let nbsp = if whitespace.render.nbsp() == WhitespaceRenderValue::All - && text.len_chars() < end - { -   - } else { - " " - }; - - use helix_core::graphemes::{grapheme_width, RopeGraphemes}; - - for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = offset.col > (visual_x as usize) - || (visual_x as usize) >= 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 as usize + visual_x - offset.col) as u16, - viewport.y + line, - &newline, - style.patch(whitespace_style), - ); - } - - draw_indent_guides(last_line_indent_level, line, surface); - - visual_x = 0; - line += 1; - is_in_indent_area = true; - - // TODO: with proper iter this shouldn't be necessary - if line >= viewport.height { - break 'outer; - } - } else { - let grapheme = Cow::from(grapheme); - let is_whitespace; - - 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 grapheme_tab_width = - helix_core::str_utils::char_to_byte_idx(&tab, visual_tab_width); - - (&tab[..grapheme_tab_width], visual_tab_width) - } else if grapheme == " " { - is_whitespace = true; - (space, 1) - } else if grapheme == "\u{00A0}" { - is_whitespace = true; - (nbsp, 1) - } else { - is_whitespace = false; - // Cow will prevent allocations if span contained in a single slice - // which should really be the majority case - let width = grapheme_width(&grapheme); - (grapheme.as_ref(), width) - }; - - let cut_off_start = offset.col.saturating_sub(visual_x as usize); - - if !out_of_bounds { - // if we're offscreen just keep going until we hit a new line - surface.set_string( - (viewport.x as usize + visual_x - offset.col) as u16, - viewport.y + line, - display_grapheme, - if is_whitespace { - style.patch(whitespace_style) - } else { - style - }, - ); - } else if cut_off_start != 0 && cut_off_start < width { - // partially on screen - let rect = Rect::new( - viewport.x as u16, - viewport.y + line, - (width - cut_off_start) as u16, - 1, - ); - surface.set_style( - rect, - if is_whitespace { - style.patch(whitespace_style) - } else { - style - }, - ); - } - - if is_in_indent_area && !(grapheme == " " || grapheme == "\t") { - draw_indent_guides(visual_x, line, surface); - is_in_indent_area = false; - last_line_indent_level = visual_x; - } - - visual_x = visual_x.saturating_add(width); - } - } - } - } - } - } - /// Render brace match, etc (meant for the focused view only) - pub fn render_focused_view_elements( + pub fn highlight_focused_view_elements( view: &View, doc: &Document, - viewport: Rect, theme: &Theme, - surface: &mut Surface, - ) { + ) -> Vec<(usize, std::ops::Range)> { // Highlight matching braces if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); use helix_core::match_brackets; let pos = doc.selection(view.id).primary().cursor(text); - let pos = match_brackets::find_matching_bracket(syntax, doc.text(), pos) - .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); - - if let Some(pos) = pos { + if let Some(pos) = match_brackets::find_matching_bracket(syntax, doc.text(), pos) { // ensure col is on screen - if (pos.col as u16) < viewport.width + view.offset.col as u16 - && pos.col >= view.offset.col - { - let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { - Style::default() - .add_modifier(Modifier::REVERSED) - .add_modifier(Modifier::DIM) - }); - - surface[(viewport.x + pos.col as u16, viewport.y + pos.row as u16)] - .set_style(style); + if let Some(highlight) = theme.find_scope_index_exact("ui.cursor.match") { + return vec![(highlight, pos..pos + 1)]; } } } + Vec::new() } /// Render bufferline at the top @@ -707,22 +575,17 @@ impl EditorView { } } - pub fn render_gutter( - editor: &Editor, - doc: &Document, + pub fn render_gutter<'d>( + editor: &'d Editor, + doc: &'d Document, view: &View, viewport: Rect, - surface: &mut Surface, theme: &Theme, is_focused: bool, + line_decorations: &mut Vec>, ) { let text = doc.text().slice(..); - let last_line = view.last_line(doc); - - // it's used inside an iterator so the collect isn't needless: - // https://github.com/rust-lang/rust-clippy/issues/6164 - #[allow(clippy::needless_collect)] - let cursors: Vec<_> = doc + let cursors: Rc<[_]> = doc .selection(view.id) .iter() .map(|range| range.cursor_line(text)) @@ -732,29 +595,36 @@ impl EditorView { 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); + let gutter_style_virtual = theme.get("ui.gutter.virtual"); + let gutter_selected_style_virtual = theme.get("ui.gutter.selected.virtual"); for gutter_type in view.gutters() { - let gutter = gutter_type.style(editor, doc, view, theme, is_focused); + 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); + // avoid lots of small allocations by reusing a text buffer for each line + let mut text = String::with_capacity(width); + let cursors = cursors.clone(); + let gutter_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { + // TODO handle softwrap in gutters + let selected = cursors.contains(&pos.doc_line); let x = viewport.x + offset; - let y = viewport.y + i as u16; + let y = viewport.y + pos.visual_line; - let gutter_style = if selected { - gutter_selected_style - } else { - gutter_style + let gutter_style = match (selected, pos.first_visual_line) { + (false, true) => gutter_style, + (true, true) => gutter_selected_style, + (false, false) => gutter_style_virtual, + (true, false) => gutter_selected_style_virtual, }; - if let Some(style) = gutter(line, selected, &mut text) { - surface.set_stringn(x, y, &text, width, gutter_style.patch(style)); + if let Some(style) = + gutter(pos.doc_line, selected, pos.first_visual_line, &mut text) + { + renderer + .surface + .set_stringn(x, y, &text, width, gutter_style.patch(style)); } else { - surface.set_style( + renderer.surface.set_style( Rect { x, y, @@ -765,14 +635,14 @@ impl EditorView { ); } text.clear(); - } + }; + line_decorations.push(Box::new(gutter_decoration)); offset += width as u16; } } pub fn render_diagnostics( - &self, doc: &Document, view: &View, viewport: Rect, @@ -827,10 +697,13 @@ impl EditorView { } /// Apply the highlighting on the lines where a cursor is active - pub fn highlight_cursorline(doc: &Document, view: &View, surface: &mut Surface, theme: &Theme) { + pub fn cursorline_decorator( + doc: &Document, + view: &View, + theme: &Theme, + ) -> Box { let text = doc.text().slice(..); - let last_line = view.last_line(doc); - + // TODO only highlight the visual line that contains the cursor instead of the full visual line let primary_line = doc.selection(view.id).primary().cursor_line(text); // The secondary_lines do contain the primary_line, it doesn't matter @@ -847,20 +720,23 @@ impl EditorView { let primary_style = theme.get("ui.cursorline.primary"); let secondary_style = theme.get("ui.cursorline.secondary"); + let viewport = view.area; - for line in view.offset.row..(last_line + 1) { + let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| { let area = Rect::new( - view.area.x, - view.area.y + (line - view.offset.row) as u16, - view.area.width, + viewport.x, + viewport.y + pos.visual_line as u16, + viewport.width, 1, ); - if primary_line == line { - surface.set_style(area, primary_style); - } else if secondary_lines.binary_search(&line).is_ok() { - surface.set_style(area, secondary_style); + if primary_line == pos.doc_line { + renderer.surface.set_style(area, primary_style); + } else if secondary_lines.binary_search(&pos.doc_line).is_ok() { + renderer.surface.set_style(area, secondary_style); } - } + }; + + Box::new(line_decoration) } /// Apply the highlighting on the columns where a cursor is active @@ -869,6 +745,8 @@ impl EditorView { view: &View, surface: &mut Surface, theme: &Theme, + viewport: Rect, + text_annotations: &TextAnnotations, ) { let text = doc.text().slice(..); @@ -884,19 +762,23 @@ impl EditorView { .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(); + let text_format = doc.text_format(viewport.width, None); for range in selection.iter() { let is_primary = primary == *range; + let cursor = range.cursor(text); + + let Position { col, .. } = + visual_offset_from_block(text, cursor, cursor, &text_format, text_annotations).0; - 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 { + if col >= view.offset.horizontal_offset + && inner_area.width > (col - view.offset.horizontal_offset) as u16 + { let area = Rect::new( - inner_area.x + (col - offset) as u16, + inner_area.x + (col - view.offset.horizontal_offset) as u16, view.area.y, 1, view.area.height, @@ -1034,7 +916,7 @@ impl EditorView { (shift_position(start), shift_position(end), t) }), ); - apply_transaction(&tx, doc, view); + doc.apply(&tx, view.id); } InsertEvent::TriggerCompletion => { let (_, doc) = current!(cxt.editor); @@ -1136,7 +1018,7 @@ impl EditorView { let pos_and_view = |editor: &Editor, row, column| { editor.tree.views().find_map(|(view, _focus)| { - view.pos_at_screen_coords(&editor.documents[&view.doc], row, column) + view.pos_at_screen_coords(&editor.documents[&view.doc], row, column, true) .map(|pos| (pos, view.id)) }) }; @@ -1163,6 +1045,7 @@ impl EditorView { } editor.focus(view_id); + editor.ensure_cursor_in_view(view_id); return EventResult::Consumed(None); } @@ -1177,8 +1060,10 @@ impl EditorView { None => return EventResult::Ignored(None), }; - let line = coords.row + view.offset.row; - if line < doc.text().len_lines() { + if let Some(char_idx) = + view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) + { + let line = doc.text().char_to_line(char_idx); commands::dap_toggle_breakpoint_impl(cxt, path, line); return EventResult::Consumed(None); } @@ -1190,7 +1075,7 @@ impl EditorView { MouseEventKind::Drag(MouseButton::Left) => { let (view, doc) = current!(cxt.editor); - let pos = match view.pos_at_screen_coords(doc, row, column) { + let pos = match view.pos_at_screen_coords(doc, row, column, true) { Some(pos) => pos, None => return EventResult::Ignored(None), }; @@ -1199,7 +1084,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) } @@ -1221,6 +1107,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) } @@ -1252,8 +1139,9 @@ impl EditorView { cxt.editor.focus(view_id); let (view, doc) = current!(cxt.editor); - let line = coords.row + view.offset.row; - if let Ok(pos) = doc.text().try_line_to_char(line) { + if let Some(pos) = + view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) + { doc.set_selection(view_id, Selection::point(pos)); if modifiers == KeyModifiers::ALT { commands::MappableCommand::dap_edit_log.execute(cxt); @@ -1327,7 +1215,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) @@ -1426,7 +1314,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); } } diff --git a/helix-term/src/ui/fuzzy_match.rs b/helix-term/src/ui/fuzzy_match.rs index e25d7328527d..e6a3f03a2fbc 100644 --- a/helix-term/src/ui/fuzzy_match.rs +++ b/helix-term/src/ui/fuzzy_match.rs @@ -4,41 +4,209 @@ use fuzzy_matcher::FuzzyMatcher; #[cfg(test)] mod test; +struct QueryAtom { + kind: QueryAtomKind, + atom: String, + ignore_case: bool, + inverse: bool, +} +impl QueryAtom { + fn new(atom: &str) -> Option { + let mut atom = atom.to_string(); + let inverse = atom.starts_with('!'); + if inverse { + atom.remove(0); + } + + let mut kind = match atom.chars().next() { + Some('^') => QueryAtomKind::Prefix, + Some('\'') => QueryAtomKind::Substring, + _ if inverse => QueryAtomKind::Substring, + _ => QueryAtomKind::Fuzzy, + }; + + if atom.starts_with(&['^', '\'']) { + atom.remove(0); + } + + if atom.is_empty() { + return None; + } + + if atom.ends_with('$') && !atom.ends_with("\\$") { + atom.pop(); + kind = if kind == QueryAtomKind::Prefix { + QueryAtomKind::Exact + } else { + QueryAtomKind::Postfix + } + } + + Some(QueryAtom { + kind, + atom: atom.replace('\\', ""), + // not ideal but fuzzy_matches only knows ascii uppercase so more consistent + // to behave the same + ignore_case: kind != QueryAtomKind::Fuzzy + && atom.chars().all(|c| c.is_ascii_lowercase()), + inverse, + }) + } + + fn indices(&self, matcher: &Matcher, item: &str, indices: &mut Vec) -> bool { + // for inverse there are no indicies to return + // just return whether we matched + if self.inverse { + return self.matches(matcher, item); + } + let buf; + let item = if self.ignore_case { + buf = item.to_ascii_lowercase(); + &buf + } else { + item + }; + let off = match self.kind { + QueryAtomKind::Fuzzy => { + if let Some((_, fuzzy_indices)) = matcher.fuzzy_indices(item, &self.atom) { + indices.extend_from_slice(&fuzzy_indices); + return true; + } else { + return false; + } + } + QueryAtomKind::Substring => { + if let Some(off) = item.find(&self.atom) { + off + } else { + return false; + } + } + QueryAtomKind::Prefix if item.starts_with(&self.atom) => 0, + QueryAtomKind::Postfix if item.ends_with(&self.atom) => item.len() - self.atom.len(), + QueryAtomKind::Exact if item == self.atom => 0, + _ => return false, + }; + + indices.extend(off..(off + self.atom.len())); + true + } + + fn matches(&self, matcher: &Matcher, item: &str) -> bool { + let buf; + let item = if self.ignore_case { + buf = item.to_ascii_lowercase(); + &buf + } else { + item + }; + let mut res = match self.kind { + QueryAtomKind::Fuzzy => matcher.fuzzy_match(item, &self.atom).is_some(), + QueryAtomKind::Substring => item.contains(&self.atom), + QueryAtomKind::Prefix => item.starts_with(&self.atom), + QueryAtomKind::Postfix => item.ends_with(&self.atom), + QueryAtomKind::Exact => item == self.atom, + }; + if self.inverse { + res = !res; + } + res + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum QueryAtomKind { + /// Item is a fuzzy match of this behaviour + /// + /// Usage: `foo` + Fuzzy, + /// Item contains query atom as a continous substring + /// + /// Usage `'foo` + Substring, + /// Item starts with query atom + /// + /// Usage: `^foo` + Prefix, + /// Item ends with query atom + /// + /// Usage: `foo$` + Postfix, + /// Item is equal to query atom + /// + /// Usage `^foo$` + Exact, +} + +#[derive(Default)] pub struct FuzzyQuery { - queries: Vec, + first_fuzzy_atom: Option, + query_atoms: Vec, +} + +fn query_atoms(query: &str) -> impl Iterator + '_ { + let mut saw_backslash = false; + query.split(move |c| { + saw_backslash = match c { + ' ' if !saw_backslash => return true, + '\\' => true, + _ => false, + }; + false + }) } impl FuzzyQuery { + pub fn refine(&self, query: &str, old_query: &str) -> (FuzzyQuery, bool) { + // TODO: we could be a lot smarter about this + let new_query = Self::new(query); + let mut is_refinement = query.starts_with(old_query); + + // if the last atom is an inverse atom adding more text to it + // will actually increase the number of matches and we can not refine + // the matches. + if is_refinement && !self.query_atoms.is_empty() { + let last_idx = self.query_atoms.len() - 1; + if self.query_atoms[last_idx].inverse + && self.query_atoms[last_idx].atom != new_query.query_atoms[last_idx].atom + { + is_refinement = false; + } + } + + (new_query, is_refinement) + } + 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() { + let mut first_fuzzy_query = None; + let query_atoms = query_atoms(query) + .filter_map(|atom| { + let atom = QueryAtom::new(atom)?; + if atom.kind == QueryAtomKind::Fuzzy && first_fuzzy_query.is_none() { + first_fuzzy_query = Some(atom.atom); None } else { - Some(query.replace("\\ ", " ")) + Some(atom) } }) .collect(); - FuzzyQuery { queries } + FuzzyQuery { + first_fuzzy_atom: first_fuzzy_query, + query_atoms, + } } 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 + // use the rank of the first fuzzzy 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)?)?; + let score = self + .first_fuzzy_atom + .as_ref() + .map_or(Some(0), |atom| matcher.fuzzy_match(item, atom))?; if self - .queries + .query_atoms .iter() - .any(|query| matcher.fuzzy_match(item, query).is_none()) + .any(|atom| !atom.matches(matcher, item)) { return None; } @@ -46,29 +214,26 @@ impl FuzzyQuery { } 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)?)?; + let (score, mut indices) = self.first_fuzzy_atom.as_ref().map_or_else( + || Some((0, Vec::new())), + |atom| matcher.fuzzy_indices(item, atom), + )?; - // 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)); + // fast path for the common case of just a single atom + if self.query_atoms.is_empty() { + return Some((score, indices)); } - for query in &self.queries[1..] { - let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?; - indicies.extend_from_slice(&matched_indicies); + for atom in &self.query_atoms { + if !atom.indices(matcher, item, &mut indices) { + return None; + } } // deadup and remove duplicate matches - indicies.sort_unstable(); - indicies.dedup(); + indices.sort_unstable(); + indices.dedup(); - Some((score, indicies)) + Some((score, indices)) } } diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index 393d24c467bc..44050aa12969 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -53,7 +53,10 @@ impl Component for SignatureHelp { let active_param_span = self.active_param_range.map(|(start, end)| { vec![( - cx.editor.theme.find_scope_index("ui.selection").unwrap(), + cx.editor + .theme + .find_scope_index_exact("ui.selection") + .unwrap(), start..end, )] }); diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 1baaf40a3185..3ae007039294 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -4,7 +4,7 @@ use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, }; -use tui::{buffer::Buffer as Surface, text::Spans, widgets::Table}; +use tui::{buffer::Buffer as Surface, widgets::Table}; pub use tui::widgets::{Cell, Row}; @@ -18,28 +18,24 @@ pub trait Item { /// Additional editor state that is used for label calculation. type Data; - fn label(&self, data: &Self::Data) -> Spans; + fn format(&self, data: &Self::Data) -> Row; fn sort_text(&self, data: &Self::Data) -> Cow { - let label: String = self.label(data).into(); + let label: String = self.format(data).cell_text().collect(); label.into() } fn filter_text(&self, data: &Self::Data) -> Cow { - let label: String = self.label(data).into(); + let label: String = self.format(data).cell_text().collect(); label.into() } - - fn row(&self, data: &Self::Data) -> Row { - Row::new(vec![Cell::from(self.label(data))]) - } } impl Item for PathBuf { /// Root prefix to strip. type Data = PathBuf; - fn label(&self, root_path: &Self::Data) -> Spans { + fn format(&self, root_path: &Self::Data) -> Row { self.strip_prefix(root_path) .unwrap_or(self) .to_string_lossy() @@ -81,7 +77,7 @@ impl Menu { Self { options, editor_data, - matcher: Box::new(Matcher::default()), + matcher: Box::default(), matches, cursor: None, widths: Vec::new(), @@ -144,10 +140,10 @@ impl Menu { let n = self .options .first() - .map(|option| option.row(&self.editor_data).cells.len()) + .map(|option| option.format(&self.editor_data).cells.len()) .unwrap_or_default(); let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| { - let row = option.row(&self.editor_data); + let row = option.format(&self.editor_data); // maintain max for each column for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { let width = cell.content.width(); @@ -224,6 +220,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 { @@ -245,12 +252,12 @@ impl Component for Menu { return EventResult::Consumed(close_fn); } // arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc) - shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { + shift!(Tab) | key!(Up) | ctrl!('p') => { self.move_up(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); return EventResult::Consumed(None); } - key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { + key!(Tab) | key!(Down) | ctrl!('n') => { // arrow down/ctrl-n/tab advances completion choice (including updating the doc) self.move_down(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); @@ -320,12 +327,9 @@ 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 rows = options + .iter() + .map(|option| option.format(&self.editor_data)); let table = Table::new(rows) .style(style) .highlight_style(selected) @@ -357,20 +361,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 cca9e9bf0b68..d7717f8cf59c 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,4 +1,5 @@ mod completion; +mod document; pub(crate) mod editor; mod fuzzy_match; mod info; @@ -14,12 +15,13 @@ mod statusline; mod text; use crate::compositor::{Component, Compositor}; +use crate::filter_picker_entry; 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}; @@ -162,6 +164,9 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi let now = Instant::now(); + let dedup_symlinks = config.file_picker.deduplicate_links; + let absolute_root = root.canonicalize().unwrap_or_else(|_| root.clone()); + let mut walk_builder = WalkBuilder::new(&root); walk_builder .hidden(config.file_picker.hidden) @@ -172,10 +177,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi .git_global(config.file_picker.git_global) .git_exclude(config.file_picker.git_exclude) .max_depth(config.file_picker.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"); + .filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks)); // We want to exclude files that the editor can't handle yet let mut type_builder = TypesBuilder::new(); @@ -194,26 +196,24 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi // We want files along with their modification date for sorting let files = walk_builder.build().filter_map(|entry| { let entry = entry.ok()?; - // This is faster than entry.path().is_dir() since it uses cached fs::Metadata fetched by ignore/walkdir - let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir()); - if is_dir { - // Will give a false positive if metadata cannot be read (eg. permission error) - None - } else { + if entry.file_type()?.is_file() { Some(entry.into_path()) + } else { + None } }); // 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)); @@ -230,7 +230,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)), ) } @@ -254,8 +254,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()) @@ -462,20 +462,30 @@ pub mod completers { use ignore::WalkBuilder; use std::path::Path; - let is_tilde = input.starts_with('~') && input.len() == 1; + let is_tilde = input == "~"; let path = helix_core::path::expand_tilde(Path::new(input)); let (dir, file_name) = if input.ends_with(std::path::MAIN_SEPARATOR) { (path, None) } else { - let file_name = path - .file_name() - .and_then(|file| file.to_str().map(|path| path.to_owned())); - - let path = match path.parent() { - Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(), - // Path::new("h")'s parent is Some("")... - _ => std::env::current_dir().expect("couldn't determine current directory"), + let is_period = (input.ends_with((format!("{}.", std::path::MAIN_SEPARATOR)).as_str()) + && input.len() > 2) + || input == "."; + let file_name = if is_period { + Some(String::from(".")) + } else { + path.file_name() + .and_then(|file| file.to_str().map(|path| path.to_owned())) + }; + + let path = if is_period { + path + } else { + match path.parent() { + Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(), + // Path::new("h")'s parent is Some("")... + _ => std::env::current_dir().expect("couldn't determine current directory"), + } }; (path, file_name) diff --git a/helix-term/src/ui/overlay.rs b/helix-term/src/ui/overlay.rs index 0b8a93ae833d..5b2bc806093d 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 2505f21972e4..5190fc531d55 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,40 +1,77 @@ use crate::{ + alt, compositor::{Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, - ui::{self, fuzzy_match::FuzzyQuery, EditorView}, + ui::{ + self, + document::{render_document, LineDecoration, LinePos, TextRenderer}, + fuzzy_match::FuzzyQuery, + EditorView, + }, }; +use futures_util::future::BoxFuture; use tui::{ buffer::Buffer as Surface, - widgets::{Block, BorderType, Borders}, + layout::Constraint, + text::{Span, Spans}, + widgets::{Block, BorderType, Borders, Cell, Table}, }; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use tui::widgets::Widget; -use std::time::Instant; -use std::{ - cmp::Reverse, - collections::HashMap, - io::Read, - path::{Path, PathBuf}, -}; +use std::cmp::{self, Ordering}; +use std::{collections::HashMap, io::Read, path::PathBuf}; use crate::ui::{Prompt, PromptEvent}; -use helix_core::{movement::Direction, Position}; +use helix_core::{ + movement::Direction, text_annotations::TextAnnotations, + unicode::segmentation::UnicodeSegmentation, Position, +}; use helix_view::{ editor::Action, graphics::{CursorKind, Margin, Modifier, Rect}, - Document, Editor, + theme::Style, + view::ViewPosition, + 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, @@ -113,62 +150,71 @@ impl FilePicker { self.picker .selection() .and_then(|current| (self.file_fn)(editor, current)) - .and_then(|(path, line)| { - helix_core::path::get_canonicalized_path(&path) - .ok() - .zip(Some(line)) - }) + .and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line))) } /// Get (cached) preview for a given path. If a document corresponding /// to the path is already open in the editor, it is used instead. fn get_preview<'picker, 'editor>( &'picker mut self, - path: &Path, + path_or_id: PathOrId, editor: &'editor Editor, ) -> Preview<'picker, 'editor> { - if let Some(doc) = editor.document_by_path(path) { - return Preview::EditorDocument(doc); - } + match path_or_id { + PathOrId::Path(path) => { + let path = &path; + if let Some(doc) = editor.document_by_path(path) { + return Preview::EditorDocument(doc); + } - if self.preview_cache.contains_key(path) { - return Preview::Cached(&self.preview_cache[path]); - } + if self.preview_cache.contains_key(path) { + return Preview::Cached(&self.preview_cache[path]); + } - let data = std::fs::File::open(path).and_then(|file| { - let metadata = file.metadata()?; - // Read up to 1kb to detect the content type - let n = file.take(1024).read_to_end(&mut self.read_buffer)?; - let content_type = content_inspector::inspect(&self.read_buffer[..n]); - self.read_buffer.clear(); - Ok((metadata, content_type)) - }); - let preview = data - .map( - |(metadata, content_type)| match (metadata.len(), content_type) { - (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary, - (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => CachedPreview::LargeFile, - _ => { - // TODO: enable syntax highlighting; blocked by async rendering - Document::open(path, None, None) - .map(|doc| CachedPreview::Document(Box::new(doc))) - .unwrap_or(CachedPreview::NotFound) - } - }, - ) - .unwrap_or(CachedPreview::NotFound); - self.preview_cache.insert(path.to_owned(), preview); - Preview::Cached(&self.preview_cache[path]) + let data = std::fs::File::open(path).and_then(|file| { + let metadata = file.metadata()?; + // Read up to 1kb to detect the content type + let n = file.take(1024).read_to_end(&mut self.read_buffer)?; + let content_type = content_inspector::inspect(&self.read_buffer[..n]); + self.read_buffer.clear(); + Ok((metadata, content_type)) + }); + let preview = data + .map( + |(metadata, content_type)| match (metadata.len(), content_type) { + (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary, + (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => { + CachedPreview::LargeFile + } + _ => { + // TODO: enable syntax highlighting; blocked by async rendering + Document::open(path, None, None, editor.config.clone()) + .map(|doc| CachedPreview::Document(Box::new(doc))) + .unwrap_or(CachedPreview::NotFound) + } + }, + ) + .unwrap_or(CachedPreview::NotFound); + self.preview_cache.insert(path.to_owned(), preview); + Preview::Cached(&self.preview_cache[path]) + } + PathOrId::Id(id) => { + let doc = editor.documents.get(&id).unwrap(); + Preview::EditorDocument(doc) + } + } } fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult { // Try to find a document in the cache let doc = self .current_file(cx.editor) - .and_then(|(path, _range)| self.preview_cache.get_mut(&path)) - .and_then(|cache| match cache { - CachedPreview::Document(doc) => Some(doc), - _ => None, + .and_then(|(path, _range)| match path { + PathOrId::Id(doc_id) => Some(doc_mut!(cx.editor, &doc_id)), + PathOrId::Path(path) => match self.preview_cache.get_mut(&path) { + Some(CachedPreview::Document(doc)) => Some(doc), + _ => None, + }, }); // Then attempt to highlight it if it has no language set @@ -225,7 +271,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 => { @@ -246,43 +292,57 @@ impl Component for FilePicker { }) .unwrap_or(0); - let offset = Position::new(first_line, 0); + let offset = ViewPosition { + anchor: doc.text().line_to_char(first_line), + horizontal_offset: 0, + vertical_offset: 0, + }; - let mut highlights = - EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme); + let mut highlights = EditorView::doc_syntax_highlights( + doc, + offset.anchor, + 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( + let mut decorations: Vec> = Vec::new(); + + if let Some((start, end)) = range { + let style = cx + .editor + .theme + .try_get("ui.highlight") + .unwrap_or_else(|| cx.editor.theme.get("ui.selection")); + let draw_highlight = move |renderer: &mut TextRenderer, pos: LinePos| { + if (start..=end).contains(&pos.doc_line) { + let area = Rect::new( + renderer.viewport.x, + renderer.viewport.y + pos.visual_line, + renderer.viewport.width, + 1, + ); + renderer.surface.set_style(area, style) + } + }; + decorations.push(Box::new(draw_highlight)) + } + + render_document( + surface, + inner, doc, offset, - inner, - surface, - &cx.editor.theme, + &TextAnnotations::default(), highlights, - &cx.editor.config(), + &cx.editor.theme, + &mut decorations, + &mut [], ); - - // highlight the line - if let Some((start, end)) = range { - let offset = start.saturating_sub(first_line) as u16; - surface.set_style( - Rect::new( - inner.x, - inner.y + offset, - inner.width, - (end.saturating_sub(start) as u16 + 1) - .min(inner.height.saturating_sub(offset)), - ), - cx.editor - .theme - .try_get("ui.highlight") - .unwrap_or_else(|| cx.editor.theme.get("ui.selection")), - ); - } } } @@ -309,13 +369,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)>, + matches: Vec, /// Current height of the completions box completion_height: u16, @@ -323,11 +407,13 @@ pub struct Picker { cursor: usize, // pattern: String, prompt: Prompt, - previous_pattern: String, + previous_pattern: (String, FuzzyQuery), /// Whether to truncate the start (default true) pub truncate_start: bool, /// Whether to show the preview panel (default true) show_preview: bool, + /// Constraints for tabular formatting + widths: Vec, callback_fn: Box, } @@ -345,95 +431,133 @@ impl Picker { |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {}, ); + let n = options + .first() + .map(|option| option.format(&editor_data).cells.len()) + .unwrap_or_default(); + let max_lens = options.iter().fold(vec![0; n], |mut acc, option| { + let row = option.format(&editor_data); + // maintain max for each column + for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { + let width = cell.content.width(); + if width > *acc { + *acc = width; + } + } + acc + }); + let widths = max_lens + .into_iter() + .map(|len| Constraint::Length(len as u16)) + .collect(); + let mut picker = Self { options, editor_data, - matcher: Box::new(Matcher::default()), + matcher: Box::default(), matches: Vec::new(), cursor: 0, prompt, - previous_pattern: String::new(), + previous_pattern: (String::new(), FuzzyQuery::default()), truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), completion_height: 0, + widths, }; // 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 } pub fn score(&mut self) { - let now = Instant::now(); - let pattern = self.prompt.line(); - if pattern == &self.previous_pattern { + if pattern == &self.previous_pattern.0 { return; } + let (query, is_refined) = self + .previous_pattern + .1 + .refine(pattern, &self.previous_pattern.0); + if pattern.is_empty() { // Fast path for no pattern. self.matches.clear(); - self.matches.extend( - self.options - .iter() - .enumerate() - .map(|(index, _option)| (index, 0)), - ); - } else if pattern.starts_with(&self.previous_pattern) { - let query = FuzzyQuery::new(pattern); + 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 is_refined { // optimization: if the pattern is a more specific version of the previous one // then we can score the filtered set. - self.matches.retain_mut(|(index, score)| { - let option = &self.options[*index]; + self.matches.retain_mut(|pmatch| { + let option = &self.options[pmatch.index]; let text = option.sort_text(&self.editor_data); match query.fuzzy_match(&text, &self.matcher) { Some(s) => { // Update the score - *score = s; + pmatch.score = s; true } None => false, } }); - self.matches - .sort_unstable_by_key(|(_, score)| Reverse(*score)); + self.matches.sort_unstable(); } else { - let query = FuzzyQuery::new(pattern); - self.matches.clear(); - 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| (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; - self.previous_pattern.clone_from(pattern); + let pattern = self.prompt.line(); + self.previous_pattern.0.clone_from(pattern); + self.previous_pattern.1 = query; + } + + 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`) @@ -478,7 +602,7 @@ impl Picker { pub fn selection(&self) -> Option<&T> { self.matches .get(self.cursor) - .map(|(index, _score)| &self.options[*index]) + .map(|pmatch| &self.options[pmatch.index]) } pub fn toggle_preview(&mut self) { @@ -543,6 +667,11 @@ impl Component for Picker { key!(Esc) | ctrl!('c') => { return close_fn; } + alt!(Enter) => { + if let Some(option) = self.selection() { + (self.callback_fn)(cx, option, Action::Load); + } + } key!(Enter) => { if let Some(option) = self.selection() { (self.callback_fn)(cx, option, Action::Replace); @@ -575,7 +704,7 @@ impl Component for Picker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let text_style = cx.editor.theme.get("ui.text"); let selected = cx.editor.theme.get("ui.text.focus"); - let highlighted = cx.editor.theme.get("special").add_modifier(Modifier::BOLD); + let highlight_style = cx.editor.theme.get("special").add_modifier(Modifier::BOLD); // -- Render the frame: // clear area @@ -615,61 +744,123 @@ impl Component for Picker { } // -- Render the contents: - // subtract area of prompt from top and current item marker " > " from left - let inner = inner.clip_top(2).clip_left(3); + // subtract area of prompt from top + let inner = inner.clip_top(2); let rows = inner.height; let offset = self.cursor - (self.cursor % std::cmp::max(1, rows as usize)); + let cursor = self.cursor.saturating_sub(offset); - let files = self + let options = self .matches .iter() .skip(offset) - .map(|(index, _score)| (*index, self.options.get(*index).unwrap())); - - for (i, (_index, option)) in files.take(rows as usize).enumerate() { - let is_active = i == (self.cursor - offset); - if is_active { - surface.set_string( - inner.x.saturating_sub(3), - inner.y + i as u16, - " > ", - selected, - ); - surface.set_style( - Rect::new(inner.x, inner.y + i as u16, inner.width, 1), - selected, - ); - } - - let spans = option.label(&self.editor_data); - 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| { - let new_x = surface - .set_string_truncated( - pos.x, - pos.y + i as u16, - &span.content, - pos.width as usize, - |idx| { - if highlights.contains(&idx) { - highlighted.patch(span.style) - } else if is_active { - selected.patch(span.style) + .take(rows as usize) + .map(|pmatch| &self.options[pmatch.index]) + .map(|option| option.format(&self.editor_data)) + .map(|mut row| { + const TEMP_CELL_SEP: &str = " "; + + let line = row.cell_text().fold(String::new(), |mut s, frag| { + s.push_str(&frag); + s.push_str(TEMP_CELL_SEP); + s + }); + + // Items are filtered by using the text returned by menu::Item::filter_text + // but we do highlighting here using the text in Row and therefore there + // might be inconsistencies. This is the best we can do since only the + // text in Row is displayed to the end user. + let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) + .fuzzy_indicies(&line, &self.matcher) + .unwrap_or_default(); + + let highlight_byte_ranges: Vec<_> = line + .char_indices() + .enumerate() + .filter_map(|(char_idx, (byte_offset, ch))| { + highlights + .contains(&char_idx) + .then(|| byte_offset..byte_offset + ch.len_utf8()) + }) + .collect(); + + // The starting byte index of the current (iterating) cell + let mut cell_start_byte_offset = 0; + for cell in row.cells.iter_mut() { + let spans = match cell.content.lines.get(0) { + Some(s) => s, + None => continue, + }; + + let mut cell_len = 0; + + let graphemes_with_style: Vec<_> = spans + .0 + .iter() + .flat_map(|span| { + span.content + .grapheme_indices(true) + .zip(std::iter::repeat(span.style)) + }) + .map(|((grapheme_byte_offset, grapheme), style)| { + cell_len += grapheme.len(); + let start = cell_start_byte_offset; + + let grapheme_byte_range = + grapheme_byte_offset..grapheme_byte_offset + grapheme.len(); + + if highlight_byte_ranges.iter().any(|hl_rng| { + hl_rng.start >= start + grapheme_byte_range.start + && hl_rng.end <= start + grapheme_byte_range.end + }) { + (grapheme, style.patch(highlight_style)) } else { - text_style.patch(span.style) + (grapheme, style) } - }, - true, - self.truncate_start, - ) - .0; - pos.clip_left(new_x - pos.x) + }) + .collect(); + + let mut span_list: Vec<(String, Style)> = Vec::new(); + for (grapheme, style) in graphemes_with_style { + if span_list.last().map(|(_, sty)| sty) == Some(&style) { + let (string, _) = span_list.last_mut().unwrap(); + string.push_str(grapheme); + } else { + span_list.push((String::from(grapheme), style)) + } + } + + let spans: Vec = span_list + .into_iter() + .map(|(string, style)| Span::styled(string, style)) + .collect(); + let spans: Spans = spans.into(); + *cell = Cell::from(spans); + + cell_start_byte_offset += cell_len + TEMP_CELL_SEP.len(); + } + + row }); - } + + let table = Table::new(options) + .style(text_style) + .highlight_style(selected) + .highlight_symbol(" > ") + .column_spacing(1) + .widths(&self.widths); + + use tui::widgets::TableState; + + table.render_table( + inner, + surface, + &mut TableState { + offset: 0, + selected: Some(cursor), + }, + ); } fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { @@ -683,3 +874,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 3c140da40aef..5a95c1bbae79 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,9 +38,14 @@ impl Popup { auto_close: false, ignore_escape_key: false, id, + has_scrollbar: true, } } + /// Set the anchor position next to which the popup should be drawn. + /// + /// Note that this is not the position of the top-left corner of the rendered popup itself, + /// but rather the screen-space position of the information to which the popup refers. pub fn position(mut self, pos: Option) -> Self { self.position = pos; self @@ -49,6 +55,10 @@ impl Popup { self.position } + /// Set the popup to prefer to render above or below the anchor position. + /// + /// This preference will be ignored if the viewport doesn't have enough space in the + /// chosen direction. pub fn position_bias(mut self, bias: Open) -> Self { self.position_bias = bias; self @@ -76,6 +86,8 @@ impl Popup { self } + /// Calculate the position where the popup should be rendered and return the coordinates of the + /// top left corner. pub fn get_rel_position(&mut self, viewport: Rect, cx: &Context) -> (u16, u16) { let position = self .position @@ -128,6 +140,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 +248,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 51ef688d7647..5fb6745a90e5 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -294,23 +294,22 @@ impl Prompt { direction: CompletionDirection, ) { (self.callback_fn)(cx, &self.line, PromptEvent::Abort); - let register = cx.editor.registers.get_mut(register).read(); - - if register.is_empty() { - return; - } + let values = match cx.editor.registers.read(register) { + Some(values) if !values.is_empty() => values, + _ => return, + }; - let end = register.len().saturating_sub(1); + let end = values.len().saturating_sub(1); let index = match direction { CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1), CompletionDirection::Backward => { - self.history_pos.unwrap_or(register.len()).saturating_sub(1) + self.history_pos.unwrap_or(values.len()).saturating_sub(1) } } .min(end); - self.line = register[index].clone(); + self.line = values[index].clone(); self.history_pos = Some(index); @@ -353,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 @@ -451,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 + }, ); } } @@ -548,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()); }; } diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 47bb11290a3c..a25b4540d1f9 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)) @@ -136,11 +139,13 @@ where match element_id { helix_view::editor::StatusLineElement::Mode => render_mode, helix_view::editor::StatusLineElement::Spinner => render_lsp_spinner, + helix_view::editor::StatusLineElement::FileBaseName => render_file_base_name, helix_view::editor::StatusLineElement::FileName => render_file_name, helix_view::editor::StatusLineElement::FileEncoding => render_file_encoding, 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 @@ -158,7 +163,8 @@ where F: Fn(&mut RenderContext, String, Option