master #1

Merged
Trivernis merged 558 commits from Mirrors/helix:master into master 2 years ago

@ -28,12 +28,10 @@ jobs:
profile: minimal profile: minimal
override: true override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Run cargo check - name: Run cargo check
uses: actions-rs/cargo@v1 run: cargo check
with:
command: check
test: test:
name: Test Suite name: Test Suite
@ -46,12 +44,9 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@1.61
with:
profile: minimal
override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Cache test tree-sitter grammar - name: Cache test tree-sitter grammar
uses: actions/cache@v3 uses: actions/cache@v3
@ -61,15 +56,10 @@ jobs:
restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars- restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo test - name: Run cargo test
uses: actions-rs/cargo@v1 run: cargo test --workspace
with:
command: test
args: --workspace
- name: Run cargo integration-test - name: Run cargo integration-test
uses: actions-rs/cargo@v1 run: cargo integration-test
with:
command: integration-test
strategy: strategy:
matrix: matrix:
@ -83,31 +73,20 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@1.61
with: with:
profile: minimal
override: true
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Run cargo fmt - name: Run cargo fmt
uses: actions-rs/cargo@v1 run: cargo fmt --all --check
with:
command: fmt
args: --all -- --check
- name: Run cargo clippy - name: Run cargo clippy
uses: actions-rs/cargo@v1 run: cargo clippy --workspace --all-targets -- -D warnings
with:
command: clippy
args: --all-targets -- -D warnings
- name: Run cargo doc - name: Run cargo doc
uses: actions-rs/cargo@v1 run: cargo doc --no-deps --workspace --document-private-items
with:
command: doc
args: --no-deps --workspace --document-private-items
env: env:
RUSTDOCFLAGS: -D warnings RUSTDOCFLAGS: -D warnings
@ -119,18 +98,15 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@1.61
with:
profile: minimal
override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Validate queries
run: cargo xtask query-check
- name: Generate docs - name: Generate docs
uses: actions-rs/cargo@v1 run: cargo xtask docgen
with:
command: xtask
args: docgen
- name: Check uncommitted documentation changes - name: Check uncommitted documentation changes
run: | run: |
@ -139,23 +115,3 @@ jobs:
|| (echo "Run 'cargo xtask docgen', commit the changes and push again" \ || (echo "Run 'cargo xtask docgen', commit the changes and push again" \
&& exit 1) && 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

@ -14,10 +14,10 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install nix - name: Install nix
uses: cachix/install-nix-action@v17 uses: cachix/install-nix-action@v18
- name: Authenticate with Cachix - name: Authenticate with Cachix
uses: cachix/cachix-action@v10 uses: cachix/cachix-action@v12
with: with:
name: helix name: helix
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

@ -11,7 +11,7 @@ indent = { tab-width = 4, unit = " " }
[[grammar]] [[grammar]]
name = "rust" name = "rust"
source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a360da0a29a19c281d08295a35ecd0544d2da211" } source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "0431a2c60828731f27491ee9fdefe25e250ce9c9" }
[[language]] [[language]]
name = "nix" name = "nix"

@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.59.0" channel = "1.61.0"
components = ["rustfmt", "rust-src"] components = ["rustfmt", "rust-src"]

@ -26,18 +26,12 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@stable
with:
profile: minimal
override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Fetch tree-sitter grammars - name: Fetch tree-sitter grammars
uses: actions-rs/cargo@v1 run: cargo run --package=helix-loader --bin=hx-loader
with:
command: run
args: --package=helix-loader --bin=hx-loader
- name: Bundle grammars - name: Bundle grammars
run: tar cJf grammars.tar.xz -C runtime/grammars/sources . run: tar cJf grammars.tar.xz -C runtime/grammars/sources .
@ -50,6 +44,16 @@ jobs:
dist: dist:
name: Dist name: Dist
needs: [fetch-grammars] 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 }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false # don't fail other jobs if one fails 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 build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc
include: include:
- build: x86_64-linux - build: x86_64-linux
os: ubuntu-20.04 os: ubuntu-latest
rust: stable rust: stable
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
cross: false cross: false
- build: aarch64-linux - build: aarch64-linux
os: ubuntu-20.04 os: ubuntu-latest
rust: stable rust: stable
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
cross: true cross: true
- build: riscv64-linux - build: riscv64-linux
os: ubuntu-20.04 os: ubuntu-latest
rust: stable rust: stable
target: riscv64gc-unknown-linux-gnu target: riscv64gc-unknown-linux-gnu
cross: true cross: true
@ -77,7 +81,7 @@ jobs:
target: x86_64-apple-darwin target: x86_64-apple-darwin
cross: false cross: false
- build: x86_64-windows - build: x86_64-windows
os: windows-2019 os: windows-latest
rust: stable rust: stable
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
cross: false cross: false
@ -110,12 +114,10 @@ jobs:
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
- name: Install ${{ matrix.rust }} toolchain - name: Install ${{ matrix.rust }} toolchain
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@master
with: with:
profile: minimal
toolchain: ${{ matrix.rust }} toolchain: ${{ matrix.rust }}
target: ${{ matrix.target }} target: ${{ matrix.target }}
override: true
# Install a pre-release version of Cross # Install a pre-release version of Cross
# TODO: We need to pre-install Cross because we need cross-rs/cross#591 to # 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. # 0.3.0, which includes cross-rs/cross#591, is released.
- name: Install Cross - name: Install Cross
if: "matrix.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 - name: Run cargo test
uses: actions-rs/cargo@v1
if: "!matrix.skip_tests" if: "!matrix.skip_tests"
with: run: ${{ env.CARGO }} test --release --locked --target ${{ matrix.target }} --workspace
use-cross: ${{ matrix.cross }}
command: test
args: --release --locked --target ${{ matrix.target }} --workspace
- name: Set profile.release.strip = true - name: Set profile.release.strip = true
shell: bash shell: bash
@ -142,11 +149,7 @@ jobs:
EOF EOF
- name: Build release binary - name: Build release binary
uses: actions-rs/cargo@v1 run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }}
with:
use-cross: ${{ matrix.cross }}
command: build
args: --release --locked --target ${{ matrix.target }}
- name: Build AppImage - name: Build AppImage
shell: bash shell: bash
@ -221,16 +224,6 @@ jobs:
- uses: actions/download-artifact@v3 - 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 - name: Build archive
shell: bash shell: bash
run: | run: |
@ -250,7 +243,7 @@ jobs:
if [[ $platform =~ "windows" ]]; then if [[ $platform =~ "windows" ]]; then
exe=".exe" exe=".exe"
fi fi
pkgname=helix-$TAG-$platform pkgname=helix-$GITHUB_REF_NAME-$platform
mkdir $pkgname mkdir $pkgname
cp $source/LICENSE $source/README.md $pkgname cp $source/LICENSE $source/README.md $pkgname
mkdir $pkgname/contrib mkdir $pkgname/contrib
@ -270,7 +263,7 @@ jobs:
fi fi
done 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/ mv dist $source/
- name: Upload binaries to release - name: Upload binaries to release
@ -280,7 +273,7 @@ jobs:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
file: dist/* file: dist/*
file_glob: true file_glob: true
tag: ${{ steps.tagname.outputs.val }} tag: ${{ github.ref_name }}
overwrite: true overwrite: true
- name: Upload binaries as artifact - name: Upload binaries as artifact

@ -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<char>` ([#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` (`<space>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 `<code>` 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 `<ret>` 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) # 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)) This is a patch release that fixes a panic caused by closing splits or buffers. ([#3633](https://github.com/helix-editor/helix/pull/3633))

1361
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -7,6 +7,7 @@ members = [
"helix-lsp", "helix-lsp",
"helix-dap", "helix-dap",
"helix-loader", "helix-loader",
"helix-vcs",
"xtask", "xtask",
] ]
@ -14,9 +15,6 @@ default-members = [
"helix-term" "helix-term"
] ]
[profile.dev]
split-debuginfo = "unpacked"
[profile.release] [profile.release]
lto = "thin" lto = "thin"
# debug = true # debug = true

@ -1,6 +1,20 @@
# Helix <div align="center">
<h1>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="logo_dark.svg">
<source media="(prefers-color-scheme: light)" srcset="logo_light.svg">
<img alt="Helix" height="128" src="logo_light.svg">
</picture>
</h1>
[![Build status](https://github.com/helix-editor/helix/actions/workflows/build.yml/badge.svg)](https://github.com/helix-editor/helix/actions) [![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)
</div>
![Screenshot](./screenshot.png) ![Screenshot](./screenshot.png)
@ -41,20 +55,41 @@ cd helix
cargo install --path helix-term cargo install --path helix-term
``` ```
This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars. This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`.
If you want to customize your `languages.toml` config,
tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`.
Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the Helix needs its runtime files so make sure to copy/symlink the `runtime/` directory into the
config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows). config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows).
| OS | Command | | OS | Command |
| -------------------- | -------------------------------------------- | | -------------------- | ------------------------------------------------ |
| Windows (cmd.exe) | `xcopy /e /i runtime %AppData%\helix\runtime` | | Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` |
| Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` | | Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` |
| Linux/macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | | Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` |
Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires
elevated privileges - i.e. PowerShell or Cmd must be run as administrator.
**PowerShell:**
```powershell
New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime"
```
**Cmd:**
```cmd
cd %appdata%\helix
mklink /D runtime "<helix-repo>\runtime"
```
The runtime location can be overridden via the `HELIX_RUNTIME` environment variable.
> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term`,
> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`.
This location can be overridden via the `HELIX_RUNTIME` environment variable. If you plan on keeping the repo locally, an alternative to copying/symlinking
runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime`
(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory).
Packages already solve this for you by wrapping the `hx` binary with a wrapper Packages already solve this for you by wrapping the `hx` binary with a wrapper
that sets the variable to the install dir. that sets the variable to the install dir.
@ -62,15 +97,35 @@ that sets the variable to the install dir.
> NOTE: running via cargo also doesn't require setting explicit `HELIX_RUNTIME` path, it will automatically > NOTE: running via cargo also doesn't require setting explicit `HELIX_RUNTIME` path, it will automatically
> detect the `runtime` directory in the project root. > detect the `runtime` directory in the project root.
If you want to customize your `languages.toml` config,
tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`.
In order to use LSP features like auto-complete, you will need to In order to use LSP features like auto-complete, you will need to
[install the appropriate Language Server](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers) [install the appropriate Language Server](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers)
for a language. for a language.
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) [![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions)
## MacOS ## Adding Helix to your desktop environment
Helix can be installed on MacOS through homebrew: If installing from source, to use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder:
```bash
cp contrib/Helix.desktop ~/.local/share/applications
```
To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`:
```bash
sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop
sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop
```
Please note: there is no icon for Helix yet, so the system default will be used.
## macOS
Helix can be installed on macOS through homebrew:
``` ```
brew install helix brew install helix
@ -85,3 +140,7 @@ Contributing guidelines can be found [here](./docs/CONTRIBUTING.md).
Your question might already be answered on the [FAQ](https://github.com/helix-editor/helix/wiki/FAQ). 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). 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!

@ -1 +1 @@
22.08.1 22.12

@ -7,6 +7,7 @@
"ui.linenr.selected" = { fg = "white", bg = "black", modifiers = ["bold"] } "ui.linenr.selected" = { fg = "white", bg = "black", modifiers = ["bold"] }
"ui.selection" = { fg = "black", bg = "blue" } "ui.selection" = { fg = "black", bg = "blue" }
"ui.selection.primary" = { fg = "white", bg = "blue" } "ui.selection.primary" = { fg = "white", bg = "blue" }
"ui.text.inactive" = { fg = "gray" }
"comment" = { fg = "gray" } "comment" = { fg = "gray" }
"ui.statusline" = { fg = "black", bg = "white" } "ui.statusline" = { fg = "black", bg = "white" }
"ui.statusline.inactive" = { fg = "gray", bg = "white" } "ui.statusline.inactive" = { fg = "gray", bg = "white" }

@ -9,3 +9,4 @@ edit-url-template = "https://github.com/helix-editor/helix/tree/master/book/{pat
cname = "docs.helix-editor.com" cname = "docs.helix-editor.com"
default-theme = "colibri" default-theme = "colibri"
preferred-dark-theme = "colibri" preferred-dark-theme = "colibri"
git-repository-url = "https://github.com/helix-editor/helix"

@ -28,22 +28,28 @@ hidden = false
You may also specify a file to use for configuration with the `-c` or You may also specify a file to use for configuration with the `-c` or
`--config` CLI argument: `hx -c path/to/custom-config.toml`. `--config` CLI argument: `hx -c path/to/custom-config.toml`.
It is also possible to trigger configuration file reloading by sending the `USR1`
signal to the helix process, e.g. via `pkill -USR1 hx`. This is only supported
on unix operating systems.
## Editor ## Editor
### `[editor]` Section ### `[editor]` Section
| Key | Description | Default | | Key | Description | Default |
|--|--|---------| |--|--|---------|
| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling. | `3` | | `scrolloff` | Number of lines of padding around the edge of the screen when scrolling. | `5` |
| `mouse` | Enable mouse mode. | `true` | | `mouse` | Enable mouse mode. | `true` |
| `middle-click-paste` | Middle click paste support. | `true` | | `middle-click-paste` | Middle click paste support. | `true` |
| `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` | | `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` |
| `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`<br/>Windows: `["cmd", "/C"]` | | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`<br/>Windows: `["cmd", "/C"]` |
| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `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` | | `cursorline` | Highlight all lines with a cursor. | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "line-numbers"]` | | `cursorcolumn` | Highlight all columns with a cursor. | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
| `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` |
| `auto-format` | Enable automatic formatting on save. | `true` | | `auto-format` | Enable automatic formatting on save. | `true` |
| `auto-save` | Enable automatic saving on focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal. | `false` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
| `auto-info` | Whether to display infoboxes | `true` | | `auto-info` | Whether to display infoboxes | `true` |
@ -68,20 +74,37 @@ left = ["mode", "spinner"]
center = ["file-name"] center = ["file-name"]
right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"] right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"]
separator = "│" separator = "│"
mode.normal = "NORMAL"
mode.insert = "INSERT"
mode.select = "SELECT"
``` ```
The `[editor.statusline]` key takes the following sub-keys:
| Key | Description | Default |
| --- | --- | --- |
| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name"]` |
| `center` | A list of elements aligned to the middle of the statusline | `[]` |
| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "position", "file-encoding"]` |
| `separator` | The character used to separate elements in the statusline | `"│"` |
| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` |
| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` |
| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` |
The following elements can be configured: The following statusline elements can be configured:
| Key | Description | | Key | Description |
| ------ | ----------- | | ------ | ----------- |
| `mode` | The current editor mode (`NOR`/`INS`/`SEL`) | | `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) |
| `spinner` | A progress spinner indicating LSP activity | | `spinner` | A progress spinner indicating LSP activity |
| `file-name` | The path/name of the opened file | | `file-name` | The path/name of the opened file |
| `file-encoding` | The encoding of the opened file if it differs from UTF-8 | | `file-encoding` | The encoding of the opened file if it differs from UTF-8 |
| `file-line-ending` | The file line endings (CRLF or LF) | | `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 | | `file-type` | The type of the opened file |
| `diagnostics` | The number of warnings and/or errors | | `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 | | `selections` | The number of active selections |
| `primary-selection-length` | The number of characters currently in primary selection |
| `position` | The cursor position | | `position` | The cursor position |
| `position-percentage` | The cursor position as a percentage of the total number of lines | | `position-percentage` | The cursor position as a percentage of the total number of lines |
| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) | | `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) |
@ -218,15 +241,17 @@ tabpad = "·" # Tabs will look like "→···" (depending on tab width)
Options for rendering vertical indent guides. Options for rendering vertical indent guides.
| Key | Description | Default | | Key | Description | Default |
| --- | --- | --- | | --- | --- | --- |
| `render` | Whether to render indent guides. | `false` | | `render` | Whether to render indent guides. | `false` |
| `character` | Literal character to use for rendering the indent guide | `│` | | `character` | Literal character to use for rendering the indent guide | `│` |
| `skip-levels` | Number of indent levels to skip | `0` |
Example: Example:
```toml ```toml
[editor.indent-guides] [editor.indent-guides]
render = true render = true
character = "╎" character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽"
skip-levels = 1
``` ```

@ -2,19 +2,24 @@
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| astro | ✓ | | | | | astro | ✓ | | | |
| awk | ✓ | ✓ | | `awk-language-server` | | awk | ✓ | ✓ | | `awk-language-server` |
| bash | ✓ | | | `bash-language-server` | | bash | ✓ | | | `bash-language-server` |
| bass | ✓ | | | `bass` | | bass | ✓ | | | `bass` |
| beancount | ✓ | | | | | beancount | ✓ | | | |
| bibtex | ✓ | | | `texlab` |
| bicep | ✓ | | | `bicep-langserver` |
| c | ✓ | ✓ | ✓ | `clangd` | | c | ✓ | ✓ | ✓ | `clangd` |
| c-sharp | ✓ | ✓ | | `OmniSharp` | | c-sharp | ✓ | ✓ | | `OmniSharp` |
| cairo | ✓ | | | | | cairo | ✓ | | | |
| clojure | ✓ | | | `clojure-lsp` | | clojure | ✓ | | | `clojure-lsp` |
| cmake | ✓ | ✓ | ✓ | `cmake-language-server` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` |
| comment | ✓ | | | | | comment | ✓ | | | |
| common-lisp | ✓ | | | `cl-lsp` |
| cpon | ✓ | | ✓ | | | cpon | ✓ | | ✓ | |
| cpp | ✓ | ✓ | ✓ | `clangd` | | cpp | ✓ | ✓ | ✓ | `clangd` |
| crystal | ✓ | | | |
| css | ✓ | | | `vscode-css-language-server` | | css | ✓ | | | `vscode-css-language-server` |
| cue | ✓ | | | `cuelsp` | | cue | ✓ | | | `cuelsp` |
| d | ✓ | ✓ | ✓ | `serve-d` |
| dart | ✓ | | ✓ | `dart` | | dart | ✓ | | ✓ | `dart` |
| devicetree | ✓ | | | | | devicetree | ✓ | | | |
| diff | ✓ | | | | | diff | ✓ | | | |
@ -23,9 +28,10 @@
| edoc | ✓ | | | | | edoc | ✓ | | | |
| eex | ✓ | | | | | eex | ✓ | | | |
| ejs | ✓ | | | | | ejs | ✓ | | | |
| elixir | ✓ | ✓ | | `elixir-ls` | | elixir | ✓ | ✓ | | `elixir-ls` |
| elm | ✓ | | | `elm-language-server` | | elm | ✓ | | | `elm-language-server` |
| elvish | ✓ | | | `elvish` | | elvish | ✓ | | | `elvish` |
| env | ✓ | | | |
| erb | ✓ | | | | | erb | ✓ | | | |
| erlang | ✓ | ✓ | | `erlang_ls` | | erlang | ✓ | ✓ | | `erlang_ls` |
| esdl | ✓ | | | | | esdl | ✓ | | | |
@ -46,19 +52,21 @@
| gowork | ✓ | | | `gopls` | | gowork | ✓ | | | `gopls` |
| graphql | ✓ | | | | | graphql | ✓ | | | |
| hare | ✓ | | | | | hare | ✓ | | | |
| haskell | ✓ | | | `haskell-language-server-wrapper` | | haskell | ✓ | | | `haskell-language-server-wrapper` |
| hcl | ✓ | | ✓ | `terraform-ls` | | hcl | ✓ | | ✓ | `terraform-ls` |
| heex | ✓ | ✓ | | | | heex | ✓ | ✓ | | `elixir-ls` |
| html | ✓ | | | `vscode-html-language-server` | | html | ✓ | | | `vscode-html-language-server` |
| idris | | | | `idris2-lsp` | | idris | | | | `idris2-lsp` |
| iex | ✓ | | | | | iex | ✓ | | | |
| java | ✓ | | | `jdtls` | | ini | ✓ | | | |
| java | ✓ | ✓ | | `jdtls` |
| javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` |
| jsdoc | ✓ | | | | | jsdoc | ✓ | | | |
| json | ✓ | | ✓ | `vscode-json-language-server` | | json | ✓ | | ✓ | `vscode-json-language-server` |
| jsonnet | ✓ | | | `jsonnet-language-server` | | jsonnet | ✓ | | | `jsonnet-language-server` |
| jsx | ✓ | ✓ | ✓ | `typescript-language-server` | | jsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| julia | ✓ | | | `julia` | | julia | ✓ | | | `julia` |
| kdl | ✓ | | | |
| kotlin | ✓ | | | `kotlin-language-server` | | kotlin | ✓ | | | `kotlin-language-server` |
| latex | ✓ | ✓ | | `texlab` | | latex | ✓ | ✓ | | `texlab` |
| lean | ✓ | | | `lean` | | lean | ✓ | | | `lean` |
@ -66,14 +74,16 @@
| llvm | ✓ | ✓ | ✓ | | | llvm | ✓ | ✓ | ✓ | |
| llvm-mir | ✓ | ✓ | ✓ | | | llvm-mir | ✓ | ✓ | ✓ | |
| llvm-mir-yaml | ✓ | | ✓ | | | llvm-mir-yaml | ✓ | | ✓ | |
| lua | ✓ | | ✓ | `lua-language-server` | | lua | ✓ | | ✓ | `lua-language-server` |
| make | ✓ | | | | | make | ✓ | | | |
| markdown | ✓ | | | `marksman` | | markdown | ✓ | | | `marksman` |
| markdown.inline | ✓ | | | | | markdown.inline | ✓ | | | |
| matlab | ✓ | | | |
| mermaid | ✓ | | | |
| meson | ✓ | | ✓ | | | meson | ✓ | | ✓ | |
| mint | | | | `mint` | | mint | | | | `mint` |
| nickel | ✓ | | ✓ | `nls` | | nickel | ✓ | | ✓ | `nls` |
| nix | ✓ | | | `rnix-lsp` | | nix | ✓ | | | `nil` |
| nu | ✓ | | | | | nu | ✓ | | | |
| ocaml | ✓ | | ✓ | `ocamllsp` | | ocaml | ✓ | | ✓ | `ocamllsp` |
| ocaml-interface | ✓ | | | `ocamllsp` | | ocaml-interface | ✓ | | | `ocamllsp` |
@ -86,9 +96,11 @@
| prisma | ✓ | | | `prisma-language-server` | | prisma | ✓ | | | `prisma-language-server` |
| prolog | | | | `swipl` | | prolog | | | | `swipl` |
| protobuf | ✓ | | ✓ | | | protobuf | ✓ | | ✓ | |
| python | ✓ | ✓ | | `pylsp` | | purescript | ✓ | | | `purescript-language-server` |
| python | ✓ | ✓ | ✓ | `pylsp` |
| qml | ✓ | | ✓ | `qmlls` |
| r | ✓ | | | `R` | | r | ✓ | | | `R` |
| racket | | | | `racket` | | racket | | | | `racket` |
| regex | ✓ | | | | | regex | ✓ | | | |
| rescript | ✓ | ✓ | | `rescript-language-server` | | rescript | ✓ | ✓ | | `rescript-language-server` |
| rmarkdown | ✓ | | ✓ | `R` | | rmarkdown | ✓ | | ✓ | `R` |
@ -118,8 +130,13 @@
| v | ✓ | | | `vls` | | v | ✓ | | | `vls` |
| vala | ✓ | | | `vala-language-server` | | vala | ✓ | | | `vala-language-server` |
| verilog | ✓ | ✓ | | `svlangserver` | | verilog | ✓ | ✓ | | `svlangserver` |
| vhs | ✓ | | | |
| vue | ✓ | | | `vls` | | vue | ✓ | | | `vls` |
| wast | ✓ | | | |
| wat | ✓ | | | |
| wgsl | ✓ | | | `wgsl_analyzer` | | wgsl | ✓ | | | `wgsl_analyzer` |
| wit | ✓ | | ✓ | |
| xit | ✓ | | | | | xit | ✓ | | | |
| xml | ✓ | | ✓ | |
| yaml | ✓ | | ✓ | `yaml-language-server` | | yaml | ✓ | | ✓ | `yaml-language-server` |
| zig | ✓ | | ✓ | `zls` | | zig | ✓ | | ✓ | `zls` |

@ -28,7 +28,7 @@
| `:quit-all!`, `:qa!` | Force close all views ignoring unsaved changes. | | `:quit-all!`, `:qa!` | Force close all views ignoring unsaved changes. |
| `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). | | `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). |
| `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). | | `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). |
| `:theme` | Change the editor theme. | | `:theme` | Change the editor theme (show current theme if no name specified). |
| `:clipboard-yank` | Yank main selection into system clipboard. | | `:clipboard-yank` | Yank main selection into system clipboard. |
| `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. | | `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. |
| `:primary-clipboard-yank` | Yank main selection into system primary clipboard. | | `:primary-clipboard-yank` | Yank main selection into system primary clipboard. |
@ -44,6 +44,10 @@
| `:show-directory`, `:pwd` | Show the current working directory. | | `:show-directory`, `:pwd` | Show the current working directory. |
| `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. | | `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. |
| `:reload` | Discard changes and reload from the source file. | | `:reload` | Discard changes and reload from the source file. |
| `:reload-all` | Discard changes and reload all documents from the source files. |
| `:update` | Write changes only if the file has been modified. |
| `:lsp-workspace-command` | Open workspace command picker |
| `:lsp-restart` | Restarts the Language Server that is in use by the current doc |
| `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. | | `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. |
| `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. | | `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. |
| `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. | | `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. |
@ -64,7 +68,8 @@
| `:config-reload` | Refresh user config. | | `:config-reload` | Refresh user config. |
| `:config-open` | Open the user config.toml file. | | `:config-open` | Open the user config.toml file. |
| `:log-open` | Open the helix log file. | | `:log-open` | Open the helix log file. |
| `:insert-output` | Run shell command, inserting output after each selection. | | `:insert-output` | Run shell command, inserting output before each selection. |
| `:append-output` | Run shell command, appending output after each selection. | | `:append-output` | Run shell command, appending output after each selection. |
| `:pipe` | Pipe each selection to the shell command. | | `: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 | | `:run-shell-command`, `:sh` | Run a shell command |

@ -46,6 +46,20 @@ capture on the same line, the indent level isn't changed at all.
- `@outdent` (default scope `all`): - `@outdent` (default scope `all`):
Decrease the indent level by 1. The same rules as for `@indent` apply. Decrease the indent level by 1. The same rules as for `@indent` apply.
- `@extend`:
Extend the range of this node to the end of the line and to lines that
are indented more than the line that this node starts on. This is useful
for languages like Python, where for the purpose of indentation some nodes
(like functions or classes) should also contain indented lines that follow them.
- `@extend.prevent-once`:
Prevents the first extension of an ancestor of this node. For example, in Python
a return expression always ends the block that it is in. Note that this only stops the
extension of the next `@extend` capture. If multiple ancestors are captured,
only the extension of the innermost one is prevented. All other ancestors are unaffected
(regardless of whether the innermost ancestor would actually have been extended).
## Predicates ## Predicates
In some cases, an S-expression cannot express exactly what pattern should be matched. In some cases, an S-expression cannot express exactly what pattern should be matched.

@ -50,6 +50,41 @@ sudo dnf install helix
sudo xbps-install helix sudo xbps-install helix
``` ```
## Windows
Helix can be installed using [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
or [MSYS2](https://msys2.org/).
**Scoop:**
```
scoop install helix
```
**Chocolatey:**
```
choco install helix
```
**MSYS2:**
```
pacman -S mingw-w64-i686-helix
```
or
```
pacman -S mingw-w64-x86_64-helix
```
or
```
pacman -S mingw-w64-ucrt-x86_64-helix
```
## Build from source ## Build from source
``` ```
@ -58,26 +93,67 @@ cd helix
cargo install --path helix-term cargo install --path helix-term
``` ```
This will install the `hx` binary to `$HOME/.cargo/bin`. This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`.
Helix also needs it's runtime files so make sure to copy/symlink the `runtime/` directory into the Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the
config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden
via the `HELIX_RUNTIME` environment variable. via the `HELIX_RUNTIME` environment variable.
| OS | command | | OS | Command |
|-------------------|-----------| | -------------------- | ------------------------------------------------ |
|windows(cmd.exe) |`xcopy /e /i runtime %AppData%/helix/runtime` | | Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` |
|windows(powershell)|`xcopy /e /i runtime $Env:AppData\helix\runtime` | | Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` |
|linux/macos |`ln -s $PWD/runtime ~/.config/helix/runtime`| | Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` |
Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires
elevated privileges - i.e. PowerShell or Cmd must be run as administrator.
**PowerShell:**
```powershell
New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime"
```
**Cmd:**
```cmd
cd %appdata%\helix
mklink /D runtime "<helix-repo>\runtime"
```
The runtime location can be overridden via the `HELIX_RUNTIME` environment variable.
> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term`,
> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`.
If you plan on keeping the repo locally, an alternative to copying/symlinking
runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime`
(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory).
To use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder:
```bash
cp contrib/Helix.desktop ~/.local/share/applications
```
To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`:
```bash
sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop
sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop
```
Please note: there is no icon for Helix yet, so the system default will be used.
## Finishing up the installation
## Finishing up the installation To make sure everything is set up as expected you should finally run the helix healthcheck via
To make sure everything is set up as expected you should finally run the helix healthcheck via
``` ```
hx --health hx --health
``` ```
For more information on the information displayed in the healthcheck results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck).
For more information on the information displayed in the health check results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck).
### Building tree-sitter grammars ### Building tree-sitter grammars

@ -68,8 +68,8 @@
| `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` | | `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` |
| `i` | Insert before selection | `insert_mode` | | `i` | Insert before selection | `insert_mode` |
| `a` | Insert after selection (append) | `append_mode` | | `a` | Insert after selection (append) | `append_mode` |
| `I` | Insert at the start of the line | `prepend_to_line` | | `I` | Insert at the start of the line | `insert_at_line_start` |
| `A` | Insert at the end of the line | `append_to_line` | | `A` | Insert at the end of the line | `insert_at_line_end` |
| `o` | Open new line below selection | `open_below` | | `o` | Open new line below selection | `open_below` |
| `O` | Open new line above selection | `open_above` | | `O` | Open new line above selection | `open_above` |
| `.` | Repeat last insert | N/A | | `.` | Repeat last insert | N/A |
@ -111,6 +111,7 @@
| `s` | Select all regex matches inside selections | `select_regex` | | `s` | Select all regex matches inside selections | `select_regex` |
| `S` | Split selection into subselections on regex matches | `split_selection` | | `S` | Split selection into subselections on regex matches | `split_selection` |
| `Alt-s` | Split selection on newlines | `split_selection_on_newline` | | `Alt-s` | Split selection on newlines | `split_selection_on_newline` |
| `Alt-_ ` | Merge consecutive selections | `merge_consecutive_selections` |
| `&` | Align selection in columns | `align_selections` | | `&` | Align selection in columns | `align_selections` |
| `_` | Trim whitespace from the selection | `trim_selections` | | `_` | Trim whitespace from the selection | `trim_selections` |
| `;` | Collapse selection onto a single cursor | `collapse_selection` | | `;` | Collapse selection onto a single cursor | `collapse_selection` |
@ -129,6 +130,7 @@
| `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | | `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` |
| `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` | | `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` |
| `J` | Join lines inside selection | `join_selections` | | `J` | Join lines inside selection | `join_selections` |
| `Alt-J` | Join lines inside selection and select space | `join_selections_space` |
| `K` | Keep selections matching the regex | `keep_selections` | | `K` | Keep selections matching the regex | `keep_selections` |
| `Alt-K` | Remove selections matching the regex | `remove_selections` | | `Alt-K` | Remove selections matching the regex | `remove_selections` |
| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | | `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` |
@ -166,10 +168,13 @@ These sub-modes are accessible from normal mode and typically switch back to nor
#### View mode #### View mode
Accessed by typing `z` in [normal mode](#normal-mode).
View mode is intended for scrolling and manipulating the view without changing View mode is intended for scrolling and manipulating the view without changing
the selection. The "sticky" variant of this mode is persistent; use the Escape the selection. The "sticky" variant of this mode (accessed by typing `Z` in
key to return to normal mode after usage (useful when you're simply looking normal mode) is persistent; use the Escape key to return to normal mode after
over text and not actively editing it). usage (useful when you're simply looking over text and not actively editing
it).
| Key | Description | Command | | Key | Description | Command |
@ -187,6 +192,8 @@ over text and not actively editing it).
#### Goto mode #### Goto mode
Accessed by typing `g` in [normal mode](#normal-mode).
Jumps to various locations. Jumps to various locations.
| Key | Description | Command | | Key | Description | Command |
@ -212,9 +219,10 @@ Jumps to various locations.
#### Match mode #### Match mode
Enter this mode using `m` from normal mode. See the relevant section Accessed by typing `m` in [normal mode](#normal-mode).
in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround)
and [textobject](./usage.md#textobject) usage. See the relevant section in [Usage](./usage.md) for an explanation about
[surround](./usage.md#surround) and [textobject](./usage.md#textobjects) usage.
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
@ -229,6 +237,8 @@ TODO: Mappings for selecting syntax nodes (a superset of `[`).
#### Window mode #### Window mode
Accessed by typing `Ctrl-w` in [normal mode](#normal-mode).
This layer is similar to Vim keybindings as Kakoune does not support window. This layer is similar to Vim keybindings as Kakoune does not support window.
| Key | Description | Command | | Key | Description | Command |
@ -251,8 +261,9 @@ This layer is similar to Vim keybindings as Kakoune does not support window.
#### Space mode #### Space mode
This layer is a kludge of mappings, mostly pickers. Accessed by typing `Space` in [normal mode](#normal-mode).
This layer is a kludge of mappings, mostly pickers.
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
@ -263,8 +274,8 @@ This layer is a kludge of mappings, mostly pickers.
| `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` | | `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` |
| `s` | Open document symbol picker (**LSP**) | `symbol_picker` | | `s` | Open document symbol picker (**LSP**) | `symbol_picker` |
| `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` | | `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` |
| `g` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` | | `d` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` |
| `G` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` | `D` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` |
| `r` | Rename symbol (**LSP**) | `rename_symbol` | | `r` | Rename symbol (**LSP**) | `rename_symbol` |
| `a` | Apply code action (**LSP**) | `code_action` | | `a` | Apply code action (**LSP**) | `code_action` |
| `'` | Open last fuzzy picker | `last_picker` | | `'` | Open last fuzzy picker | `last_picker` |
@ -277,7 +288,7 @@ This layer is a kludge of mappings, mostly pickers.
| `/` | Global search in workspace folder | `global_search` | | `/` | Global search in workspace folder | `global_search` |
| `?` | Open command palette | `command_palette` | | `?` | Open command palette | `command_palette` |
> TIP: Global search displays results in a fuzzy picker, use `space + '` to bring it back up after opening a file. > TIP: Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file.
##### Popup ##### Popup
@ -287,7 +298,7 @@ Displays documentation for item under cursor.
| ---- | ----------- | | ---- | ----------- |
| `Ctrl-u` | Scroll up | | `Ctrl-u` | Scroll up |
| `Ctrl-d` | Scroll down | | `Ctrl-d` | Scroll down |
#### Unimpaired #### Unimpaired
Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired).
@ -300,20 +311,24 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire
| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | | `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` |
| `]f` | Go to next function (**TS**) | `goto_next_function` | | `]f` | Go to next function (**TS**) | `goto_next_function` |
| `[f` | Go to previous function (**TS**) | `goto_prev_function` | | `[f` | Go to previous function (**TS**) | `goto_prev_function` |
| `]c` | Go to next class (**TS**) | `goto_next_class` | | `]t` | Go to next type definition (**TS**) | `goto_next_class` |
| `[c` | Go to previous class (**TS**) | `goto_prev_class` | | `[t` | Go to previous type definition (**TS**) | `goto_prev_class` |
| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` | | `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` |
| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | | `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` |
| `]o` | Go to next comment (**TS**) | `goto_next_comment` | | `]c` | Go to next comment (**TS**) | `goto_next_comment` |
| `[o` | Go to previous comment (**TS**) | `goto_prev_comment` | | `[c` | Go to previous comment (**TS**) | `goto_prev_comment` |
| `]t` | Go to next test (**TS**) | `goto_next_test` | | `]T` | Go to next test (**TS**) | `goto_next_test` |
| `]t` | Go to previous test (**TS**) | `goto_prev_test` | | `[T` | Go to previous test (**TS**) | `goto_prev_test` |
| `]p` | Go to next paragraph | `goto_next_paragraph` | | `]p` | Go to next paragraph | `goto_next_paragraph` |
| `[p` | Go to previous paragraph | `goto_prev_paragraph` | | `[p` | Go to previous paragraph | `goto_prev_paragraph` |
| `[space` | Add newline above | `add_newline_above` | | `]g` | Go to next change | `goto_next_change` |
| `]space` | Add newline below | `add_newline_below` | | `[g` | Go to previous change | `goto_prev_change` |
| `]G` | Go to first change | `goto_first_change` |
| `[G` | Go to last change | `goto_last_change` |
| `[Space` | Add newline above | `add_newline_above` |
| `]Space` | Add newline below | `add_newline_below` |
## Insert Mode ## Insert mode
Insert mode bindings are somewhat minimal by default. Helix is designed to Insert mode bindings are somewhat minimal by default. Helix is designed to
be a modal editor, and this is reflected in the user experience and internal be a modal editor, and this is reflected in the user experience and internal
@ -322,44 +337,47 @@ escaping from insert mode to normal mode. For this reason, new users are
strongly encouraged to learn the modal editing paradigm to get the smoothest strongly encouraged to learn the modal editing paradigm to get the smoothest
experience. experience.
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
| `Escape` | Switch to normal mode | `normal_mode` | | `Escape` | Switch to normal mode | `normal_mode` |
| `Ctrl-x` | Autocomplete | `completion` | | `Ctrl-s` | Commit undo checkpoint | `commit_undo_checkpoint` |
| `Ctrl-r` | Insert a register content | `insert_register` | | `Ctrl-x` | Autocomplete | `completion` |
| `Ctrl-w`, `Alt-Backspace`, `Ctrl-Backspace` | Delete previous word | `delete_word_backward` | | `Ctrl-r` | Insert a register content | `insert_register` |
| `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | `delete_word_forward` | | `Ctrl-w`, `Alt-Backspace` | Delete previous word | `delete_word_backward` |
| `Ctrl-u` | Delete to start of line | `kill_to_line_start` | | `Alt-d`, `Alt-Delete` | Delete next word | `delete_word_forward` |
| `Ctrl-k` | Delete to end of line | `kill_to_line_end` | | `Ctrl-u` | Delete to start of line | `kill_to_line_start` |
| `Ctrl-j`, `Enter` | Insert new line | `insert_newline` | | `Ctrl-k` | Delete to end of line | `kill_to_line_end` |
| `Backspace`, `Ctrl-h` | Delete previous char | `delete_char_backward` | | `Ctrl-h`, `Backspace` | Delete previous char | `delete_char_backward` |
| `Delete`, `Ctrl-d` | Delete next char | `delete_char_forward` | | `Ctrl-d`, `Delete` | Delete next char | `delete_char_forward` |
| `Ctrl-j`, `Enter` | Insert new line | `insert_newline` |
However, if you really want navigation in insert mode, this is supported. An
example config that gives the ability to use arrow keys while still in insert These keys are not recommended, but are included for new users less familiar
mode: with modal editors.
| Key | Description | Command |
| ----- | ----------- | ------- |
| `Up` | Move to previous line | `move_line_up` |
| `Down` | Move to next line | `move_line_down` |
| `Left` | Backward a char | `move_char_left` |
| `Right` | Forward a char | `move_char_right` |
| `PageUp` | Move one page up | `page_up` |
| `PageDown` | Move one page down | `page_down` |
| `Home` | Move to line start | `goto_line_start` |
| `End` | Move to line end | `goto_line_end_newline` |
If you want to disable them in insert mode as you become more comfortable with modal editing, you can use
the following in your `config.toml`:
```toml ```toml
[keys.insert] [keys.insert]
"up" = "move_line_up" up = "no_op"
"down" = "move_line_down" down = "no_op"
"left" = "move_char_left" left = "no_op"
"right" = "move_char_right" right = "no_op"
"C-b" = "move_char_left" pageup = "no_op"
"C-f" = "move_char_right" pagedown = "no_op"
"A-b" = "move_prev_word_end" home = "no_op"
"C-left" = "move_prev_word_end" end = "no_op"
"A-f" = "move_next_word_start"
"C-right" = "move_next_word_start"
"A-<" = "goto_file_start"
"A->" = "goto_file_end"
"pageup" = "page_up"
"pagedown" = "page_down"
"home" = "goto_line_start"
"C-a" = "goto_line_start"
"end" = "goto_line_end_newline"
"C-e" = "goto_line_end_newline"
"A-left" = "goto_line_start"
``` ```
## Select / extend mode ## Select / extend mode
@ -381,13 +399,12 @@ Keys to use within picker. Remapping currently not supported.
| Key | Description | | Key | Description |
| ----- | ------------- | | ----- | ------------- |
| `Tab`, `Up`, `Ctrl-p` | Previous entry | | `Shift-Tab`, `Up`, `Ctrl-p` | Previous entry |
| `Tab`, `Down`, `Ctrl-n` | Next entry |
| `PageUp`, `Ctrl-u` | Page up | | `PageUp`, `Ctrl-u` | Page up |
| `Shift-tab`, `Down`, `Ctrl-n`| Next entry |
| `PageDown`, `Ctrl-d` | Page down | | `PageDown`, `Ctrl-d` | Page down |
| `Home` | Go to first entry | | `Home` | Go to first entry |
| `End` | Go to last entry | | `End` | Go to last entry |
| `Ctrl-space` | Filter options |
| `Enter` | Open selected | | `Enter` | Open selected |
| `Ctrl-s` | Open horizontally | | `Ctrl-s` | Open horizontally |
| `Ctrl-v` | Open vertically | | `Ctrl-v` | Open vertically |
@ -411,8 +428,8 @@ Keys to use within prompt, Remapping currently not supported.
| `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | | `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word |
| `Ctrl-u` | Delete to start of line | | `Ctrl-u` | Delete to start of line |
| `Ctrl-k` | Delete to end of line | | `Ctrl-k` | Delete to end of line |
| `backspace`, `Ctrl-h` | Delete previous char | | `Backspace`, `Ctrl-h` | Delete previous char |
| `delete`, `Ctrl-d` | Delete next char | | `Delete`, `Ctrl-d` | Delete next char |
| `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later | | `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later |
| `Ctrl-p`, `Up` | Select previous history | | `Ctrl-p`, `Up` | Select previous history |
| `Ctrl-n`, `Down` | Select next history | | `Ctrl-n`, `Down` | Select next history |

@ -39,7 +39,7 @@ injection-regex = "^mylang$"
file-types = ["mylang", "myl"] file-types = ["mylang", "myl"]
comment-token = "#" comment-token = "#"
indent = { tab-width = 2, unit = " " } 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"] } formatter = { command = "mylang-formatter" , args = ["--stdin"] }
``` ```
@ -50,7 +50,7 @@ These configuration keys are available:
| `name` | The name of the language | | `name` | The name of the language |
| `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.<name>` or `text.<name>` in case of markup languages | | `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.<name>` or `text.<name>` in case of markup languages |
| `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | | `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. |
| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. Extensions and full file names are supported. | | `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. See the file-type detection section below. |
| `shebangs` | The interpreters from the shebang line, for example `["sh", "bash"]` | | `shebangs` | The interpreters from the shebang line, for example `["sh", "bash"]` |
| `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | | `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` |
| `auto-format` | Whether to autoformat this language when saving | | `auto-format` | Whether to autoformat this language when saving |
@ -61,6 +61,33 @@ These configuration keys are available:
| `config` | Language Server configuration | | `config` | Language Server configuration |
| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) | | `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 | | `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `max-line-length` | Maximum line length. Used for the `:reflow` command |
### File-type detection and the `file-types` key
Helix determines which language configuration to use with the `file-types` key
from the above section. `file-types` is a list of strings or tables, for
example:
```toml
file-types = ["Makefile", "toml", { suffix = ".git/config" }]
```
When determining a language configuration to use, Helix searches the file-types
with the following priorities:
1. Exact match: if the filename of a file is an exact match of a string in a
`file-types` list, that language wins. In the example above, `"Makefile"`
will match against `Makefile` files.
2. Extension: if there are no exact matches, any `file-types` string that
matches the file extension of a given file wins. In the example above, the
`"toml"` matches files like `Cargo.toml` or `languages.toml`.
3. Suffix: if there are still no matches, any values in `suffix` tables
are checked against the full path of the given file. In the example above,
the `{ suffix = ".git/config" }` would match against any `config` files
in `.git` directories. Note: `/` is used as the directory separator but is
replaced at runtime with the appropriate path separator for the operating
system, so this rule would match against `.git\config` files on Windows.
### Language Server configuration ### Language Server configuration
@ -72,6 +99,7 @@ The `language-server` field takes the following keys:
| `args` | A list of arguments to pass to the language server binary | | `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` | | `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 | | `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` 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 sub-table within `config` can be used to pass extra formatting options to

@ -11,11 +11,11 @@ this:
```toml ```toml
# At most one section each of 'keys.normal', 'keys.insert' and 'keys.select' # At most one section each of 'keys.normal', 'keys.insert' and 'keys.select'
[keys.normal] [keys.normal]
C-s = ":w" # Maps the Control-s to the typable command :w which is an alias for :write (save file) C-s = ":w" # Maps the Ctrl-s to the typable command :w which is an alias for :write (save file)
C-o = ":open ~/.config/helix/config.toml" # Maps the Control-o to opening of the helix config file C-o = ":open ~/.config/helix/config.toml" # Maps the Ctrl-o to opening of the helix config file
a = "move_char_left" # Maps the 'a' key to the move_char_left command a = "move_char_left" # Maps the 'a' key to the move_char_left command
w = "move_line_up" # Maps the 'w' key move_line_up w = "move_line_up" # Maps the 'w' key move_line_up
"C-S-esc" = "extend_line" # Maps Control-Shift-Escape to extend_line "C-S-esc" = "extend_line" # Maps Ctrl-Shift-Escape to extend_line
g = { a = "code_action" } # Maps `ga` to show possible code actions g = { a = "code_action" } # Maps `ga` to show possible code actions
"ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode "ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode
@ -25,7 +25,7 @@ j = { k = "normal_mode" } # Maps `jk` to exit insert mode
``` ```
> NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command. > NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command.
Control, Shift and Alt modifiers are encoded respectively with the prefixes Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes
`C-`, `S-` and `A-`. Special keys are encoded as follows: `C-`, `S-` and `A-`. Special keys are encoded as follows:
| Key name | Representation | | Key name | Representation |

@ -13,10 +13,10 @@ The default theme.toml can be found [here](https://github.com/helix-editor/helix
Each line in the theme file is specified as below: Each line in the theme file is specified as below:
```toml ```toml
key = { fg = "#ffffff", bg = "#000000", modifiers = ["bold", "italic"] } key = { fg = "#ffffff", bg = "#000000", underline = { color = "#ff0000", style = "curl"}, modifiers = ["bold", "italic"] }
``` ```
where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, and `modifiers` is a list of style modifiers. `bg` and `modifiers` can be omitted to defer to the defaults. where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, `underline` the underline `style`/`color`, and `modifiers` is a list of style modifiers. `bg`, `underline` and `modifiers` can be omitted to defer to the defaults.
To specify only the foreground color: To specify only the foreground color:
@ -77,17 +77,50 @@ The following values may be used as modifiers.
Less common modifiers might not be supported by your terminal emulator. Less common modifiers might not be supported by your terminal emulator.
| Modifier |
| --- |
| `bold` |
| `dim` |
| `italic` |
| `underlined` |
| `slow_blink` |
| `rapid_blink` |
| `reversed` |
| `hidden` |
| `crossed_out` |
> Note: The `underlined` modifier is deprecated and only available for backwards compatibility.
> Its behavior is equivalent to setting `underline.style="line"`.
### Underline Style
One of the following values may be used as a value for `underline.style`.
Some styles might not be supported by your terminal emulator.
| Modifier | | Modifier |
| --- | | --- |
| `bold` | | `line` |
| `dim` | | `curl` |
| `italic` | | `dashed` |
| `underlined` | | `dotted` |
| `slow_blink` | | `double_line` |
| `rapid_blink` |
| `reversed` |
| `hidden` | ### Inheritance
| `crossed_out` |
Extend upon other themes by setting the `inherits` property to an existing theme.
```toml
inherits = "boo_berry"
# Override the theming for "keyword"s:
"keyword" = { fg = "gold" }
# Override colors in the palette:
[palette]
berry = "#2A2A4D"
```
### Scopes ### Scopes
@ -210,49 +243,54 @@ These scopes are used for theming the editor interface.
- `hover` - for hover popup ui - `hover` - for hover popup ui
| Key | Notes | | Key | Notes |
| --- | --- | | --- | --- |
| `ui.background` | | | `ui.background` | |
| `ui.background.separator` | Picker separator below input line | | `ui.background.separator` | Picker separator below input line |
| `ui.cursor` | | | `ui.cursor` | |
| `ui.cursor.insert` | | | `ui.cursor.insert` | |
| `ui.cursor.select` | | | `ui.cursor.select` | |
| `ui.cursor.match` | Matching bracket etc. | | `ui.cursor.match` | Matching bracket etc. |
| `ui.cursor.primary` | Cursor with primary selection | | `ui.cursor.primary` | Cursor with primary selection |
| `ui.linenr` | Line numbers | | `ui.gutter` | Gutter |
| `ui.linenr.selected` | Line number for the line the cursor is on | | `ui.gutter.selected` | Gutter for the line the cursor is on |
| `ui.statusline` | Statusline | | `ui.linenr` | Line numbers |
| `ui.statusline.inactive` | Statusline (unfocused document) | | `ui.linenr.selected` | Line number for the line the cursor is on |
| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | | `ui.statusline` | Statusline |
| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | | `ui.statusline.inactive` | Statusline (unfocused document) |
| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | | `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) |
| `ui.statusline.separator` | Separator character in statusline | | `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) |
| `ui.popup` | Documentation popups (e.g space-k) | | `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) |
| `ui.popup.info` | Prompt for multiple key options | | `ui.statusline.separator` | Separator character in statusline |
| `ui.window` | Border lines separating splits | | `ui.popup` | Documentation popups (e.g Space + k) |
| `ui.help` | Description box for commands | | `ui.popup.info` | Prompt for multiple key options |
| `ui.text` | Command prompts, popup text, etc. | | `ui.window` | Border lines separating splits |
| `ui.text.focus` | | | `ui.help` | Description box for commands |
| `ui.text.info` | The key: command text in `ui.popup.info` boxes | | `ui.text` | Command prompts, popup text, etc. |
| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section])| | `ui.text.focus` | |
| `ui.virtual.whitespace` | Visible white-space characters | | `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) |
| `ui.virtual.indent-guide` | Vertical indent width guides | | `ui.text.info` | The key: command text in `ui.popup.info` boxes |
| `ui.menu` | Code and command completion menus | | `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) |
| `ui.menu.selected` | Selected autocomplete item | | `ui.virtual.whitespace` | Visible whitespace characters |
| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | | `ui.virtual.indent-guide` | Vertical indent width guides |
| `ui.selection` | For selections in the editing area | | `ui.menu` | Code and command completion menus |
| `ui.selection.primary` | | | `ui.menu.selected` | Selected autocomplete item |
| `ui.cursorline.primary` | The line of the primary cursor | | `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
| `ui.cursorline.secondary` | The lines of any other cursors | | `ui.selection` | For selections in the editing area |
| `warning` | Diagnostics warning (gutter) | | `ui.selection.primary` | |
| `error` | Diagnostics error (gutter) | | `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) |
| `info` | Diagnostics info (gutter) | | `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) |
| `hint` | Diagnostics hint (gutter) | | `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) |
| `diagnostic` | Diagnostics fallback style (editing area) | | `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) |
| `diagnostic.hint` | Diagnostics hint (editing area) | | `warning` | Diagnostics warning (gutter) |
| `diagnostic.info` | Diagnostics info (editing area) | | `error` | Diagnostics error (gutter) |
| `diagnostic.warning` | Diagnostics warning (editing area) | | `info` | Diagnostics info (gutter) |
| `diagnostic.error` | Diagnostics error (editing area) | | `hint` | Diagnostics hint (gutter) |
| `diagnostic` | Diagnostics fallback style (editing area) |
| `diagnostic.hint` | Diagnostics hint (editing area) |
| `diagnostic.info` | Diagnostics info (editing area) |
| `diagnostic.warning` | Diagnostics warning (editing area) |
| `diagnostic.error` | Diagnostics error (editing area) |
You can check compliance to spec with You can check compliance to spec with

@ -53,7 +53,7 @@ Multiple characters are currently not supported, but planned.
## Syntax-tree Motions ## Syntax-tree Motions
`A-p`, `A-o`, `A-i`, and `A-n` (or `Alt` and arrow keys) move the primary `Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) move the primary
selection according to the selection's place in the syntax tree. Let's walk selection according to the selection's place in the syntax tree. Let's walk
through an example to get familiar with them. Many languages have a syntax like through an example to get familiar with them. Many languages have a syntax like
so for function calls: so for function calls:
@ -100,13 +100,13 @@ in the tree above.
func([arg1], arg2, arg3) func([arg1], arg2, arg3)
``` ```
Using `A-n` would select the next sibling in the syntax tree: `arg2`. Using `Alt-n` would select the next sibling in the syntax tree: `arg2`.
``` ```
func(arg1, [arg2], arg3) func(arg1, [arg2], arg3)
``` ```
While `A-o` would expand the selection to the parent node. In the tree above we While `Alt-o` would expand the selection to the parent node. In the tree above we
can see that we would select the `arguments` node. can see that we would select the `arguments` node.
``` ```
@ -114,10 +114,10 @@ func[(arg1, arg2, arg3)]
``` ```
There is also some nuanced behavior that prevents you from getting stuck on a There is also some nuanced behavior that prevents you from getting stuck on a
node with no sibling. If we have a selection on `arg1`, `A-p` would bring us node with no sibling. If we have a selection on `arg1`, `Alt-p` would bring us
to the previous child node. Since `arg1` doesn't have a sibling to its left, to the previous child node. Since `arg1` doesn't have a sibling to its left,
though, we climb the syntax tree and then take the previous selection. So `A-p` though, we climb the syntax tree and then take the previous selection. So
will move the selection over to the "func" `identifier`. `Alt-p` will move the selection over to the "func" `identifier`.
``` ```
[func](arg1, arg2, arg3) [func](arg1, arg2, arg3)
@ -143,6 +143,7 @@ will move the selection over to the "func" `identifier`.
| `a` | Argument/parameter | | `a` | Argument/parameter |
| `o` | Comment | | `o` | Comment |
| `t` | Test | | `t` | Test |
| `g` | Change |
> NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current > 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 document and a special tree-sitter query file to work properly. [Only

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 42 KiB

@ -1,22 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199.7 184.2"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" viewBox="663.38 37.57 575.35 903.75"> <g transform="matrix(1,0,0,1,-31352.7,-1817.25)"> <g transform="matrix(1,0,0,1,31062.7,-20.8972)"> <g transform="matrix(1,0,0,1,-130.173,0.00185558)"> <path d="M1083.58,1875.72L1635.06,2194.12C1649.8,2202.63 1658.88,2218.37 1658.88,2235.39C1658.88,2264.98 1658.88,2311.74 1658.88,2341.33C1658.88,2349.84 1656.61,2358.03 1652.5,2365.16C1652.5,2365.16 1214.7,2112.4 1107.2,2050.33C1092.58,2041.89 1083.58,2026.29 1083.58,2009.41C1083.58,1963.5 1083.58,1875.72 1083.58,1875.72Z" style="fill:#706bc8;"></path> </g> <g transform="matrix(1,0,0,1,-130.173,0.00185558)"> <path d="M1635.26,2604.84C1649.88,2613.28 1658.88,2628.87 1658.88,2645.75C1658.88,2691.67 1658.88,2779.44 1658.88,2779.44L1107.41,2461.05C1092.66,2452.53 1083.58,2436.8 1083.58,2419.78C1083.58,2390.19 1083.58,2343.42 1083.58,2313.84C1083.58,2305.32 1085.85,2297.13 1089.96,2290.01C1089.96,2290.01 1527.76,2542.77 1635.26,2604.84Z" style="fill:#55c5e4;"></path> </g> <g transform="matrix(1,0,0,1,216.062,984.098)"> <path d="M790.407,1432.56C785.214,1435.55 780.717,1439.9 777.509,1445.46C767.862,1462.16 773.473,1483.76 790.004,1493.59L789.998,1493.59L761.173,1476.95C746.427,1468.44 737.344,1452.71 737.344,1435.68C737.344,1406.09 737.344,1359.33 737.344,1329.74C737.344,1312.71 746.427,1296.98 761.173,1288.47L1259.59,1000.74L1259.83,1000.6C1264.92,997.617 1269.33,993.314 1272.48,987.844C1282.13,971.136 1276.52,949.544 1259.99,939.707L1260,939.707L1288.82,956.349C1303.57,964.862 1312.65,980.595 1312.65,997.622C1312.65,1027.21 1312.65,1073.97 1312.65,1103.56C1312.65,1120.59 1303.57,1136.32 1288.82,1144.83L1259.19,1161.94L1259.59,1161.68L790.407,1432.56Z" style="fill:#84ddea;"></path> </g> <g transform="matrix(1,0,0,1,216.062,984.098)"> <path d="M790.407,1686.24C785.214,1689.23 780.717,1693.58 777.509,1699.13C767.862,1715.84 773.473,1737.43 790.004,1747.27L789.998,1747.27L761.173,1730.63C746.427,1722.12 737.344,1706.38 737.344,1689.36C737.344,1659.77 737.344,1613.01 737.344,1583.42C737.344,1566.39 746.427,1550.66 761.173,1542.15L1259.59,1254.42L1259.83,1254.28C1264.92,1251.29 1269.33,1246.99 1272.48,1241.52C1282.13,1224.81 1276.52,1203.22 1259.99,1193.38L1260,1193.38L1288.82,1210.03C1303.57,1218.54 1312.65,1234.27 1312.65,1251.3C1312.65,1280.89 1312.65,1327.65 1312.65,1357.24C1312.65,1374.26 1303.57,1390 1288.82,1398.51L1259.19,1415.61L1259.59,1415.36L790.407,1686.24Z" style="fill:#997bc8;"></path></g></g></g> </svg>
<style>
@media (prefers-color-scheme: dark) {
svg { fill: white; }
}
</style>
<path d="M189.5,36.8c0.2,2.8,0,5.1-0.6,6.8L153,162c-0.6,2.1-2,3.7-4.2,5c-2.2,1.2-4.4,1.9-6.7,1.9H31.4c-9.6,0-15.3-2.8-17.3-8.4
c-0.8-2.2-0.8-3.9,0.1-5.2c0.9-1.2,2.4-1.8,4.6-1.8H123c7.4,0,12.6-1.4,15.4-4.1s5.7-8.9,8.6-18.4l32.9-108.6
c1.8-5.9,1-11.1-2.2-15.6S169.9,0,164,0H72.7c-1,0-3.1,0.4-6.1,1.1l0.1-0.4C64.5,0.2,62.6,0,61,0.1s-3,0.5-4.3,1.4
c-1.3,0.9-2.4,1.8-3.2,2.8S52,6.5,51.2,8.1c-0.8,1.6-1.4,3-1.9,4.3s-1.1,2.7-1.8,4.2c-0.7,1.5-1.3,2.7-2,3.7c-0.5,0.6-1.2,1.5-2,2.5
s-1.6,2-2.2,2.8s-0.9,1.5-1.1,2.2c-0.2,0.7-0.1,1.8,0.2,3.2c0.3,1.4,0.4,2.4,0.4,3.1c-0.3,3-1.4,6.9-3.3,11.6
c-1.9,4.7-3.6,8.1-5.1,10.1c-0.3,0.4-1.2,1.3-2.6,2.7c-1.4,1.4-2.3,2.6-2.6,3.7c-0.3,0.4-0.3,1.5-0.1,3.4c0.3,1.8,0.4,3.1,0.3,3.8
c-0.3,2.7-1.3,6.3-3,10.8c-1.7,4.5-3.4,8.2-5,11c-0.2,0.5-0.9,1.4-2,2.8c-1.1,1.4-1.8,2.5-2,3.4c-0.2,0.6-0.1,1.8,0.1,3.4
c0.2,1.6,0.2,2.8-0.1,3.6c-0.6,3-1.8,6.7-3.6,11c-1.8,4.3-3.6,7.9-5.4,11c-0.5,0.8-1.1,1.7-2,2.8c-0.8,1.1-1.5,2-2,2.8
s-0.8,1.6-1,2.5c-0.1,0.5,0,1.3,0.4,2.3c0.3,1.1,0.4,1.9,0.4,2.6c-0.1,1.1-0.2,2.6-0.5,4.4c-0.2,1.8-0.4,2.9-0.4,3.2
c-1.8,4.8-1.7,9.9,0.2,15.2c2.2,6.2,6.2,11.5,11.9,15.8c5.7,4.3,11.7,6.4,17.8,6.4h110.7c5.2,0,10.1-1.7,14.7-5.2s7.7-7.8,9.2-12.9
l33-108.6c1.8-5.8,1-10.9-2.2-15.5C194.9,39.7,192.6,38,189.5,36.8z M59.6,122.8L73.8,80c0,0,7,0,10.8,0s28.8-1.7,25.4,17.5
c-3.4,19.2-18.8,25.2-36.8,25.4S59.6,122.8,59.6,122.8z M78.6,116.8c4.7-0.1,18.9-2.9,22.1-17.1S89.2,86.3,89.2,86.3l-8.9,0
l-10.2,30.5C70.2,116.9,74,116.9,78.6,116.8z M75.3,68.7L89,26.2h9.8l0.8,34l23.6-34h9.9l-13.6,42.5h-7.1l12.5-35.4l-24.5,35.4h-6.8
l-0.8-35L82,68.7H75.3z"/>
</svg>
<!-- Original image Copyright Dave Gandy — CC BY 4.0 License -->

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@ -16,8 +16,8 @@ _hx() {
COMPREPLY=($(compgen -W "$languages" -- $2)) COMPREPLY=($(compgen -W "$languages" -- $2))
;; ;;
*) *)
COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config" -- $2)) COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- $2))
;; ;;
esac esac
} && complete -F _hx hx } && complete -o filenames -F _hx hx

@ -36,6 +36,11 @@ set edit:completion:arg-completer[hx] = {|@args|
edit:complete-filename $args[-1] | each { |v| put $v[stem] } edit:complete-filename $args[-1] | each { |v| put $v[stem] }
return return
} }
# When we have --log, we need a file
if (has-values "log" $args[-2]) {
edit:complete-filename $args[-1] | each { |v| put $v[stem] }
return
}
} }
edit:complete-filename $args[-1] | each { |v| put $v[stem]} edit:complete-filename $args[-1] | each { |v| put $v[stem]}
$candidate "--help" "(Prints help information)" $candidate "--help" "(Prints help information)"
@ -46,4 +51,5 @@ set edit:completion:arg-completer[hx] = {|@args|
$candidate "--vsplit" "(Splits all given files vertically)" $candidate "--vsplit" "(Splits all given files vertically)"
$candidate "--hsplit" "(Splits all given files horizontally)" $candidate "--hsplit" "(Splits all given files horizontally)"
$candidate "--config" "(Specifies a file to use for configuration)" $candidate "--config" "(Specifies a file to use for configuration)"
} $candidate "--log" "(Specifies a file to write log data into)"
}

@ -11,4 +11,5 @@ complete -c hx -s v -o vv -o vvv -d "Increases logging verbosity"
complete -c hx -s V -l version -d "Prints version information" complete -c hx -s V -l version -d "Prints version information"
complete -c hx -l vsplit -d "Splits all given files vertically into different windows" complete -c hx -l vsplit -d "Splits all given files vertically into different windows"
complete -c hx -l hsplit -d "Splits all given files horizontally into different windows" complete -c hx -l hsplit -d "Splits all given files horizontally into different windows"
complete -c hx -s c -l config -d "Specifies a file to use for completion" complete -c hx -s c -l config -r -d "Specifies a file to use for completion"
complete -c hx -l log -r -d "Specifies a file to write log data into"

@ -18,6 +18,7 @@ _hx() {
"--hsplit[Splits all given files horizontally into different windows]" \ "--hsplit[Splits all given files horizontally into different windows]" \
"-c[Specifies a file to use for configuration]" \ "-c[Specifies a file to use for configuration]" \
"--config[Specifies a file to use for configuration]" \ "--config[Specifies a file to use for configuration]" \
"--log[Specifies a file to write log data into]" \
"*:file:_files" "*:file:_files"
case "$state" in case "$state" in

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -35,8 +35,15 @@ to `cargo install` anything either).
Integration tests for helix-term can be run with `cargo integration-test`. Code Integration tests for helix-term can be run with `cargo integration-test`. Code
contributors are strongly encouraged to write integration tests for their code. contributors are strongly encouraged to write integration tests for their code.
Existing tests can be used as examples. Helpers can be found in Existing tests can be used as examples. Helpers can be found in
[helpers.rs][helpers.rs] [helpers.rs][helpers.rs]. The log level can be set with the `HELIX_LOG_LEVEL`
environment variable, e.g. `HELIX_LOG_LEVEL=debug cargo integration-test`.
## Minimum Stable Rust Version (MSRV) Policy
Helix follows the MSRV of Firefox.
The current MSRV and future changes to the MSRV are listed in the [Firefox documentation].
[Firefox documentation]: https://firefox-source-docs.mozilla.org/writing-rust-code/update-policy.html
[good-first-issue]: https://github.com/helix-editor/helix/labels/E-easy [good-first-issue]: https://github.com/helix-editor/helix/labels/E-easy
[log-file]: https://github.com/helix-editor/helix/wiki/FAQ#access-the-log-file [log-file]: https://github.com/helix-editor/helix/wiki/FAQ#access-the-log-file
[architecture.md]: ./architecture.md [architecture.md]: ./architecture.md

@ -3,11 +3,11 @@
"crane": { "crane": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1661875961, "lastModified": 1670900067,
"narHash": "sha256-f1h/2c6Teeu1ofAHWzrS8TwBPcnN+EEu+z1sRVmMQTk=", "narHash": "sha256-VXVa+KBfukhmWizaiGiHRVX/fuk66P8dgSFfkVN4/MY=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "d9f394e4e20e97c2a60c3ad82c2b6ef99be19e24", "rev": "59b31b41a589c0a65e4a1f86b0e5eac68081468b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -19,11 +19,11 @@
"devshell": { "devshell": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1660811669, "lastModified": 1667210711,
"narHash": "sha256-V6lmsaLNFz41myppL0yxglta92ijkSvpZ+XVygAh+bU=", "narHash": "sha256-IoErjXZAkzYWHEpQqwu/DeRNJGFdR7X2OGbkhMqMrpw=",
"owner": "numtide", "owner": "numtide",
"repo": "devshell", "repo": "devshell",
"rev": "c2feacb46ee69949124c835419861143c4016fb5", "rev": "96a9dd12b8a447840cc246e17a47b81a4268bba7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -35,45 +35,49 @@
"dream2nix": { "dream2nix": {
"inputs": { "inputs": {
"alejandra": [ "alejandra": [
"nci", "nci"
"nixpkgs" ],
"all-cabal-json": [
"nci"
], ],
"crane": "crane", "crane": "crane",
"devshell": [ "devshell": [
"nci", "nci",
"devshell" "devshell"
], ],
"flake-parts": "flake-parts",
"flake-utils-pre-commit": [ "flake-utils-pre-commit": [
"nci", "nci"
"nixpkgs" ],
"ghc-utils": [
"nci"
], ],
"gomod2nix": [ "gomod2nix": [
"nci", "nci"
"nixpkgs"
], ],
"mach-nix": [ "mach-nix": [
"nci", "nci"
"nixpkgs" ],
"nix-pypi-fetcher": [
"nci"
], ],
"nixpkgs": [ "nixpkgs": [
"nci", "nci",
"nixpkgs" "nixpkgs"
], ],
"poetry2nix": [ "poetry2nix": [
"nci", "nci"
"nixpkgs"
], ],
"pre-commit-hooks": [ "pre-commit-hooks": [
"nci", "nci"
"nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1662176993, "lastModified": 1671323629,
"narHash": "sha256-Sy7DsGAveDUFBb6YDsUSYZd/AcXfP/MOMIwMt/NgY84=", "narHash": "sha256-9KHTPjIDjfnzZ4NjpE3gGIVHVHopy6weRDYO/7Y3hF8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "dream2nix", "repo": "dream2nix",
"rev": "809bc5940214744eb29778a9a0b03f161979c1b2", "rev": "2d7d68505c8619410df2c6b6463985f97cbcba6e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -82,13 +86,31 @@
"type": "github" "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": { "flake-utils": {
"locked": { "locked": {
"lastModified": 1656928814, "lastModified": 1659877975,
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -109,11 +131,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1662177071, "lastModified": 1671430291,
"narHash": "sha256-x6XF//RdZlw81tFAYM1TkjY+iQIpyMCWZ46r9o4wVQY=", "narHash": "sha256-UIc7H8F3N8rK72J/Vj5YJdV72tvDvYjH+UPsOFvlcsE=",
"owner": "yusdacra", "owner": "yusdacra",
"repo": "nix-cargo-integration", "repo": "nix-cargo-integration",
"rev": "65270dea87bb82fc02102a15221677eea237680e", "rev": "b1b0d38b8c3b0d0e6a38638d5bbe10b0bc67522c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -124,11 +146,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1662019588, "lastModified": 1671359686,
"narHash": "sha256-oPEjHKGGVbBXqwwL+UjsveJzghWiWV0n9ogo1X6l4cw=", "narHash": "sha256-3MpC6yZo+Xn9cPordGz2/ii6IJpP2n8LE8e/ebUXLrs=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2da64a81275b68fdad38af669afeda43d401e94b", "rev": "04f574a1c0fde90b51bf68198e2297ca4e7cccf4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -138,6 +160,24 @@
"type": "github" "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": { "root": {
"inputs": { "inputs": {
"nci": "nci", "nci": "nci",
@ -153,11 +193,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1662087605, "lastModified": 1671416426,
"narHash": "sha256-Gpf2gp2JenKGf+TylX/YJpttY2bzsnvAMLdLaxoZRyU=", "narHash": "sha256-kpSH1Jrxfk2qd0pRPJn1eQdIOseGv5JuE+YaOrqU9s4=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "60c2cfaa8b90ed8cebd18b214fac8682dcf222dd", "rev": "fbaaff24f375ac25ec64268b0a0d63f91e474b7d",
"type": "github" "type": "github"
}, },
"original": { "original": {

@ -21,57 +21,124 @@
... ...
}: let }: let
lib = nixpkgs.lib; lib = nixpkgs.lib;
ncl = nci.lib.nci-lib;
mkRootPath = rel: mkRootPath = rel:
builtins.path { builtins.path {
path = "${toString ./.}/${rel}"; path = "${toString ./.}/${rel}";
name = rel; name = rel;
}; };
filteredSource = let
pathsToIgnore = [
".envrc"
".ignore"
".github"
"runtime"
"screenshot.png"
"book"
"contrib"
"docs"
"README.md"
"CHANGELOG.md"
"shell.nix"
"default.nix"
"grammars.nix"
"flake.nix"
"flake.lock"
];
ignorePaths = path: type: let
# split the nix store path into its components
components = lib.splitString "/" path;
# drop off the `/nix/hash-source` section from the path
relPathComponents = lib.drop 4 components;
# reassemble the path components
relPath = lib.concatStringsSep "/" relPathComponents;
in
lib.all (p: ! (lib.hasPrefix p relPath)) pathsToIgnore;
in
builtins.path {
name = "helix-source";
path = toString ./.;
# filter out unnecessary paths
filter = ignorePaths;
};
outputs = nci.lib.makeOutputs { outputs = nci.lib.makeOutputs {
root = ./.; root = ./.;
renameOutputs = {"helix-term" = "helix";}; config = common: {
# Set default app to hx (binary is from helix-term release build) outputs = {
# Set default package to helix-term release build # rename helix-term to helix since it's our main package
defaultOutputs = { rename = {"helix-term" = "helix";};
app = "hx"; # Set default app to hx (binary is from helix-term release build)
package = "helix"; # Set default package to helix-term release build
defaults = {
app = "hx";
package = "helix";
};
};
cCompiler.package = with common.pkgs;
if stdenv.isLinux
then gcc
else clang;
shell = {
packages = with common.pkgs;
[lld_13 cargo-flamegraph rust-analyzer]
++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
++ (lib.optional stdenv.isLinux lldb);
env = [
{
name = "HELIX_RUNTIME";
eval = "$PWD/runtime";
}
{
name = "RUST_BACKTRACE";
value = "1";
}
{
name = "RUSTFLAGS";
value =
if common.pkgs.stdenv.isLinux
then "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment"
else "";
}
];
};
}; };
overrides = { pkgConfig = common: {
cCompiler = common: helix-term = {
with common.pkgs; # Wrap helix with runtime
if stdenv.isLinux wrapper = _: old: let
then gcc inherit (common) pkgs;
else clang; makeOverridableHelix = old: config: let
crateOverrides = common: _: { grammars = pkgs.callPackage ./grammars.nix config;
helix-term = prev: { runtimeDir = pkgs.runCommand "helix-runtime" {} ''
src = builtins.path { mkdir -p $out
name = "helix-source"; ln -s ${mkRootPath "runtime"}/* $out
path = toString ./.; rm -r $out/grammars
# filter out unneeded stuff that cause rebuilds ln -s ${grammars} $out/grammars
filter = path: type: '';
lib.all helix-wrapped =
(n: builtins.baseNameOf path != n) common.internal.pkgsSet.utils.wrapDerivation old
[ {
".envrc" nativeBuildInputs = [pkgs.makeWrapper];
".ignore" makeWrapperArgs = config.makeWrapperArgs or [];
".github" }
"runtime" ''
"screenshot.png" rm -rf $out/bin
"book" mkdir -p $out/bin
"contrib" ln -sf ${old}/bin/* $out/bin/
"docs" wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}"
"README.md" '';
"shell.nix" in
"default.nix" helix-wrapped
"grammars.nix" // {override = makeOverridableHelix old;};
"flake.nix" in
"flake.lock" makeOverridableHelix old {};
]; overrides.fix-build.overrideAttrs = prev: {
}; src = filteredSource;
# disable fetching and building of tree-sitter grammars in the helix-term build.rs # disable fetching and building of tree-sitter grammars in the helix-term build.rs
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1"; HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
buildInputs = (prev.buildInputs or []) ++ [common.cCompiler.cc.lib]; buildInputs = ncl.addBuildInputs prev [common.config.cCompiler.package.cc.lib];
# link languages and theme toml files since helix-term expects them (for tests) # link languages and theme toml files since helix-term expects them (for tests)
preConfigure = '' preConfigure = ''
@ -83,92 +150,25 @@
["languages.toml" "theme.toml" "base16_theme.toml"] ["languages.toml" "theme.toml" "base16_theme.toml"]
} }
''; '';
checkPhase = ":";
meta.mainProgram = "hx"; meta.mainProgram = "hx";
}; };
}; };
shell = common: prev: {
packages =
prev.packages
++ (
with common.pkgs;
[lld_13 cargo-flamegraph rust-analyzer]
++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
++ (lib.optional stdenv.isLinux lldb)
);
env =
prev.env
++ [
{
name = "HELIX_RUNTIME";
eval = "$PWD/runtime";
}
{
name = "RUST_BACKTRACE";
value = "1";
}
{
name = "RUSTFLAGS";
value =
if common.pkgs.stdenv.isLinux
then "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment"
else "";
}
];
};
}; };
}; };
makeOverridableHelix = system: old: config: let
pkgs = nixpkgs.legacyPackages.${system};
grammars = pkgs.callPackage ./grammars.nix config;
runtimeDir = pkgs.runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${mkRootPath "runtime"}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
helix-wrapped =
pkgs.runCommand "${old.name}-wrapped"
{
inherit (old) pname version meta;
nativeBuildInputs = [pkgs.makeWrapper];
makeWrapperArgs = config.makeWrapperArgs or [];
}
''
mkdir -p $out
cp -r --no-preserve=mode,ownership ${old}/* $out/
chmod +x $out/bin/*
wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}"
'';
in
helix-wrapped
// {override = makeOverridableHelix system old;};
in in
outputs outputs
// { // {
apps =
lib.mapAttrs
(
system: apps: rec {
default = hx;
hx = {
type = "app";
program = lib.getExe self.${system}.packages.helix;
};
}
)
outputs.apps;
packages = packages =
lib.mapAttrs lib.mapAttrs
( (
system: packages: rec { system: packages:
default = helix; packages
helix = makeOverridableHelix system helix-unwrapped {}; // {
helix-debug = makeOverridableHelix system helix-unwrapped-debug {}; helix-unwrapped = packages.helix.passthru.unwrapped;
helix-unwrapped = packages.helix; helix-unwrapped-dev = packages.helix-dev.passthru.unwrapped;
helix-unwrapped-debug = packages.helix-debug; }
}
) )
outputs.packages; outputs.packages;
}; };

@ -2,7 +2,7 @@
stdenv, stdenv,
lib, lib,
runCommandLocal, runCommandLocal,
runCommandNoCC, runCommand,
yj, yj,
includeGrammarIf ? _: true, includeGrammarIf ? _: true,
... ...
@ -115,7 +115,7 @@
builtins.map (grammar: "ln -s ${grammar.artifact}/${grammar.name}.so $out/${grammar.name}.so") builtins.map (grammar: "ln -s ${grammar.artifact}/${grammar.name}.so $out/${grammar.name}.so")
builtGrammars; builtGrammars;
in in
runCommandNoCC "consolidated-helix-grammars" {} '' runCommand "consolidated-helix-grammars" {} ''
mkdir -p $out mkdir -p $out
${builtins.concatStringsSep "\n" grammarLinks} ${builtins.concatStringsSep "\n" grammarLinks}
'' ''

@ -17,32 +17,35 @@ integration = []
[dependencies] [dependencies]
helix-loader = { version = "0.6", path = "../helix-loader" } helix-loader = { version = "0.6", path = "../helix-loader" }
ropey = { version = "1.5", default-features = false, features = ["simd"] } ropey = { version = "1.5.1-alpha", default-features = false, features = ["simd"] }
smallvec = "1.9" smallvec = "1.10"
smartstring = "1.0.1" smartstring = "1.0.1"
unicode-segmentation = "1.9" unicode-segmentation = "1.10"
unicode-width = "0.1" unicode-width = "0.1"
unicode-general-category = "0.5" unicode-general-category = "0.6"
# slab = "0.4.2" # slab = "0.4.2"
slotmap = "1.0" slotmap = "1.0"
tree-sitter = "0.20" tree-sitter = "0.20"
once_cell = "1.14" once_cell = "1.16"
arc-swap = "1" arc-swap = "1"
regex = "1" regex = "1"
bitflags = "1.3"
ahash = "0.8.2"
hashbrown = { version = "0.13.1", features = ["raw"] }
log = "0.4" log = "0.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.5" toml = "0.5"
similar = "2.2" imara-diff = "0.1.0"
encoding_rs = "0.8" encoding_rs = "0.8"
chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] }
etcetera = "0.4" etcetera = "0.4"
textwrap = "0.15.0" textwrap = "0.16.0"
[dev-dependencies] [dev-dependencies]
quickcheck = { version = "1", default-features = false } quickcheck = { version = "1", default-features = false }

@ -7,7 +7,6 @@ use std::collections::HashMap;
use smallvec::SmallVec; use smallvec::SmallVec;
// Heavily based on https://github.com/codemirror/closebrackets/ // Heavily based on https://github.com/codemirror/closebrackets/
pub const DEFAULT_PAIRS: &[(char, char)] = &[ pub const DEFAULT_PAIRS: &[(char, char)] = &[
('(', ')'), ('(', ')'),
('{', '}'), ('{', '}'),
@ -147,13 +146,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
} }
/// calculate what the resulting range should be for an auto pair insertion /// calculate what the resulting range should be for an auto pair insertion
fn get_next_range( fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range {
doc: &Rope,
start_range: &Range,
offset: usize,
typed_char: char,
len_inserted: usize,
) -> Range {
// When the character under the cursor changes due to complete pair // When the character under the cursor changes due to complete pair
// insertion, we must look backward a grapheme and then add the length // insertion, we must look backward a grapheme and then add the length
// of the insertion to put the resulting cursor in the right place, e.g. // of the insertion to put the resulting cursor in the right place, e.g.
@ -173,8 +166,8 @@ fn get_next_range(
// inserting at the very end of the document after the last newline // inserting at the very end of the document after the last newline
if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() { if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() {
return Range::new( return Range::new(
start_range.anchor + offset + typed_char.len_utf8(), start_range.anchor + offset + 1,
start_range.head + offset + typed_char.len_utf8(), start_range.head + offset + 1,
); );
} }
@ -204,21 +197,18 @@ fn get_next_range(
// trivial case: only inserted a single-char opener, just move the selection // trivial case: only inserted a single-char opener, just move the selection
if len_inserted == 1 { if len_inserted == 1 {
let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward { let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward {
start_range.anchor + offset + typed_char.len_utf8() start_range.anchor + offset + 1
} else { } else {
start_range.anchor + offset start_range.anchor + offset
}; };
return Range::new( return Range::new(end_anchor, start_range.head + offset + 1);
end_anchor,
start_range.head + offset + typed_char.len_utf8(),
);
} }
// If the head = 0, then we must be in insert mode with a backward // If the head = 0, then we must be in insert mode with a backward
// cursor, which implies the head will just move // cursor, which implies the head will just move
let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward { let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward {
start_range.head + offset + typed_char.len_utf8() start_range.head + offset + 1
} else { } else {
// We must have a forward cursor, which means we must move to the // We must have a forward cursor, which means we must move to the
// other end of the grapheme to get to where the new characters // other end of the grapheme to get to where the new characters
@ -244,8 +234,7 @@ fn get_next_range(
(_, Direction::Forward) => { (_, Direction::Forward) => {
if single_grapheme { if single_grapheme {
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) + 1
+ typed_char.len_utf8()
// if we are appending, the anchor stays where it is; only offset // if we are appending, the anchor stays where it is; only offset
// for multiple range insertions // for multiple range insertions
@ -259,7 +248,9 @@ fn get_next_range(
// if we're backward, then the head is at the first char // if we're backward, then the head is at the first char
// of the typed char, so we need to add the length of // of the typed char, so we need to add the length of
// the closing char // the closing char
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor)
+ len_inserted
+ offset
} else { } else {
// when we are inserting in front of a selection, we need to move // when we are inserting in front of a selection, we need to move
// the anchor over by however many characters were inserted overall // the anchor over by however many characters were inserted overall
@ -280,9 +271,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let next_char = doc.get_char(cursor); let next_char = doc.get_char(cursor);
let len_inserted; let len_inserted;
// Since auto pairs are currently limited to single chars, we're either
// inserting exactly one or two chars. When arbitrary length pairs are
// added, these will need to be changed.
let change = match next_char { let change = match next_char {
Some(_) if !pair.should_close(doc, start_range) => { Some(_) if !pair.should_close(doc, start_range) => {
len_inserted = pair.open.len_utf8(); len_inserted = 1;
let mut tendril = Tendril::new(); let mut tendril = Tendril::new();
tendril.push(pair.open); tendril.push(pair.open);
(cursor, cursor, Some(tendril)) (cursor, cursor, Some(tendril))
@ -290,12 +284,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
_ => { _ => {
// insert open & close // insert open & close
let pair_str = Tendril::from_iter([pair.open, pair.close]); let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = pair.open.len_utf8() + pair.close.len_utf8(); len_inserted = 2;
(cursor, cursor, Some(pair_str)) (cursor, cursor, Some(pair_str))
} }
}; };
let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range); end_ranges.push(next_range);
offs += len_inserted; offs += len_inserted;
@ -309,7 +303,6 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0; let mut offs = 0;
let transaction = Transaction::change_by_selection(doc, selection, |start_range| { let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
@ -321,13 +314,13 @@ fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
// return transaction that moves past close // return transaction that moves past close
(cursor, cursor, None) // no-op (cursor, cursor, None) // no-op
} else { } else {
len_inserted += pair.close.len_utf8(); len_inserted = 1;
let mut tendril = Tendril::new(); let mut tendril = Tendril::new();
tendril.push(pair.close); tendril.push(pair.close);
(cursor, cursor, Some(tendril)) (cursor, cursor, Some(tendril))
}; };
let next_range = get_next_range(doc, start_range, offs, pair.close, len_inserted); let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range); end_ranges.push(next_range);
offs += len_inserted; offs += len_inserted;
@ -363,11 +356,11 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
pair_str.push(pair.close); pair_str.push(pair.close);
} }
len_inserted += pair_str.len(); len_inserted += pair_str.chars().count();
(cursor, cursor, Some(pair_str)) (cursor, cursor, Some(pair_str))
}; };
let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range); end_ranges.push(next_range);
offs += len_inserted; offs += len_inserted;
@ -378,551 +371,3 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
log::debug!("auto pair transaction: {:#?}", t); log::debug!("auto pair transaction: {:#?}", t);
t t
} }
#[cfg(test)]
mod test {
use super::*;
use smallvec::smallvec;
const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str();
fn differing_pairs() -> impl Iterator<Item = &'static (char, char)> {
DEFAULT_PAIRS.iter().filter(|(open, close)| open != close)
}
fn matching_pairs() -> impl Iterator<Item = &'static (char, char)> {
DEFAULT_PAIRS.iter().filter(|(open, close)| open == close)
}
fn test_hooks(
in_doc: &Rope,
in_sel: &Selection,
ch: char,
pairs: &[(char, char)],
expected_doc: &Rope,
expected_sel: &Selection,
) {
let pairs = AutoPairs::new(pairs.iter());
let trans = hook(in_doc, in_sel, ch, &pairs).unwrap();
let mut actual_doc = in_doc.clone();
assert!(trans.apply(&mut actual_doc));
assert_eq!(expected_doc, &actual_doc);
assert_eq!(expected_sel, trans.selection().unwrap());
}
fn test_hooks_with_pairs<I, F, R>(
in_doc: &Rope,
in_sel: &Selection,
test_pairs: I,
pairs: &[(char, char)],
get_expected_doc: F,
actual_sel: &Selection,
) where
I: IntoIterator<Item = &'static (char, char)>,
F: Fn(char, char) -> R,
R: Into<Rope>,
Rope: From<R>,
{
test_pairs.into_iter().for_each(|(open, close)| {
test_hooks(
in_doc,
in_sel,
*open,
pairs,
&Rope::from(get_expected_doc(*open, *close)),
actual_sel,
)
});
}
// [] indicates range
/// [] -> insert ( -> ([])
#[test]
fn test_insert_blank() {
test_hooks_with_pairs(
&Rope::from(LINE_END),
&Selection::single(1, 0),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, close| format!("{}{}{}", open, close, LINE_END),
&Selection::single(2, 1),
);
let empty_doc = Rope::from(format!("{line_end}{line_end}", line_end = LINE_END));
test_hooks_with_pairs(
&empty_doc,
&Selection::single(empty_doc.len_chars(), LINE_END.len()),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, close| {
format!(
"{line_end}{open}{close}{line_end}",
open = open,
close = close,
line_end = LINE_END
)
},
&Selection::single(LINE_END.len() + 2, LINE_END.len() + 1),
);
}
#[test]
fn test_insert_before_multi_code_point_graphemes() {
for (_, close) in differing_pairs() {
test_hooks(
&Rope::from(format!("hello 👨‍👩‍👧‍👦 goodbye{}", LINE_END)),
&Selection::single(13, 6),
*close,
DEFAULT_PAIRS,
&Rope::from(format!("hello {}👨‍👩‍👧‍👦 goodbye{}", close, LINE_END)),
&Selection::single(14, 7),
);
}
}
#[test]
fn test_insert_at_end_of_document() {
test_hooks_with_pairs(
&Rope::from(LINE_END),
&Selection::single(LINE_END.len(), LINE_END.len()),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, close| format!("{}{}{}", LINE_END, open, close),
&Selection::single(LINE_END.len() + 1, LINE_END.len() + 1),
);
test_hooks_with_pairs(
&Rope::from(format!("foo{}", LINE_END)),
&Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, close| format!("foo{}{}{}", LINE_END, open, close),
&Selection::single(LINE_END.len() + 4, LINE_END.len() + 4),
);
}
/// [] -> append ( -> ([])
#[test]
fn test_append_blank() {
test_hooks_with_pairs(
// this is what happens when you have a totally blank document and then append
&Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)),
// before inserting the pair, the cursor covers all of both empty lines
&Selection::single(0, LINE_END.len() * 2),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, close| {
format!(
"{line_end}{open}{close}{line_end}",
line_end = LINE_END,
open = open,
close = close
)
},
// after inserting pair, the cursor covers the first new line and the open char
&Selection::single(0, LINE_END.len() + 2),
);
}
/// [] ([])
/// [] -> insert -> ([])
/// [] ([])
#[test]
fn test_insert_blank_multi_cursor() {
test_hooks_with_pairs(
&Rope::from("\n\n\n"),
&Selection::new(
smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),),
0,
),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, close| {
format!(
"{open}{close}\n{open}{close}\n{open}{close}\n",
open = open,
close = close
)
},
&Selection::new(
smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),),
0,
),
);
}
/// fo[o] -> append ( -> fo[o(])
#[test]
fn test_append() {
test_hooks_with_pairs(
&Rope::from("foo\n"),
&Selection::single(2, 4),
differing_pairs(),
DEFAULT_PAIRS,
|open, close| format!("foo{}{}\n", open, close),
&Selection::single(2, 5),
);
}
/// foo[] -> append to end of line ( -> foo([])
#[test]
fn test_append_single_cursor() {
test_hooks_with_pairs(
&Rope::from(format!("foo{}", LINE_END)),
&Selection::single(3, 3 + LINE_END.len()),
differing_pairs(),
DEFAULT_PAIRS,
|open, close| format!("foo{}{}{}", open, close, LINE_END),
&Selection::single(4, 5),
);
}
/// fo[o] fo[o(])
/// fo[o] -> append ( -> fo[o(])
/// fo[o] fo[o(])
#[test]
fn test_append_multi() {
test_hooks_with_pairs(
&Rope::from("foo\nfoo\nfoo\n"),
&Selection::new(
smallvec!(Range::new(2, 4), Range::new(6, 8), Range::new(10, 12)),
0,
),
differing_pairs(),
DEFAULT_PAIRS,
|open, close| {
format!(
"foo{open}{close}\nfoo{open}{close}\nfoo{open}{close}\n",
open = open,
close = close
)
},
&Selection::new(
smallvec!(Range::new(2, 5), Range::new(8, 11), Range::new(14, 17)),
0,
),
);
}
/// ([)] -> insert ) -> ()[]
#[test]
fn test_insert_close_inside_pair() {
for (open, close) in DEFAULT_PAIRS {
let doc = Rope::from(format!("{}{}{}", open, close, LINE_END));
test_hooks(
&doc,
&Selection::single(2, 1),
*close,
DEFAULT_PAIRS,
&doc,
&Selection::single(2 + LINE_END.len(), 2),
);
}
}
/// [(]) -> append ) -> [()]
#[test]
fn test_append_close_inside_pair() {
for (open, close) in DEFAULT_PAIRS {
let doc = Rope::from(format!("{}{}{}", open, close, LINE_END));
test_hooks(
&doc,
&Selection::single(0, 2),
*close,
DEFAULT_PAIRS,
&doc,
&Selection::single(0, 2 + LINE_END.len()),
);
}
}
/// ([]) ()[]
/// ([]) -> insert ) -> ()[]
/// ([]) ()[]
#[test]
fn test_insert_close_inside_pair_multi_cursor() {
let sel = Selection::new(
smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),),
0,
);
let expected_sel = Selection::new(
smallvec!(Range::new(3, 2), Range::new(6, 5), Range::new(9, 8),),
0,
);
for (open, close) in DEFAULT_PAIRS {
let doc = Rope::from(format!(
"{open}{close}\n{open}{close}\n{open}{close}\n",
open = open,
close = close
));
test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel);
}
}
/// [(]) [()]
/// [(]) -> append ) -> [()]
/// [(]) [()]
#[test]
fn test_append_close_inside_pair_multi_cursor() {
let sel = Selection::new(
smallvec!(Range::new(0, 2), Range::new(3, 5), Range::new(6, 8),),
0,
);
let expected_sel = Selection::new(
smallvec!(Range::new(0, 3), Range::new(3, 6), Range::new(6, 9),),
0,
);
for (open, close) in DEFAULT_PAIRS {
let doc = Rope::from(format!(
"{open}{close}\n{open}{close}\n{open}{close}\n",
open = open,
close = close
));
test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel);
}
}
/// ([]) -> insert ( -> (([]))
#[test]
fn test_insert_open_inside_pair() {
let sel = Selection::single(2, 1);
let expected_sel = Selection::single(3, 2);
for (open, close) in differing_pairs() {
let doc = Rope::from(format!("{}{}", open, close));
let expected_doc = Rope::from(format!(
"{open}{open}{close}{close}",
open = open,
close = close
));
test_hooks(
&doc,
&sel,
*open,
DEFAULT_PAIRS,
&expected_doc,
&expected_sel,
);
}
}
/// [word(]) -> append ( -> [word((]))
#[test]
fn test_append_open_inside_pair() {
let sel = Selection::single(0, 6);
let expected_sel = Selection::single(0, 7);
for (open, close) in differing_pairs() {
let doc = Rope::from(format!("word{}{}", open, close));
let expected_doc = Rope::from(format!(
"word{open}{open}{close}{close}",
open = open,
close = close
));
test_hooks(
&doc,
&sel,
*open,
DEFAULT_PAIRS,
&expected_doc,
&expected_sel,
);
}
}
/// ([]) -> insert " -> ("[]")
#[test]
fn test_insert_nested_open_inside_pair() {
let sel = Selection::single(2, 1);
let expected_sel = Selection::single(3, 2);
for (outer_open, outer_close) in differing_pairs() {
let doc = Rope::from(format!("{}{}", outer_open, outer_close,));
for (inner_open, inner_close) in matching_pairs() {
let expected_doc = Rope::from(format!(
"{}{}{}{}",
outer_open, inner_open, inner_close, outer_close
));
test_hooks(
&doc,
&sel,
*inner_open,
DEFAULT_PAIRS,
&expected_doc,
&expected_sel,
);
}
}
}
/// [(]) -> append " -> [("]")
#[test]
fn test_append_nested_open_inside_pair() {
let sel = Selection::single(0, 2);
let expected_sel = Selection::single(0, 3);
for (outer_open, outer_close) in differing_pairs() {
let doc = Rope::from(format!("{}{}", outer_open, outer_close,));
for (inner_open, inner_close) in matching_pairs() {
let expected_doc = Rope::from(format!(
"{}{}{}{}",
outer_open, inner_open, inner_close, outer_close
));
test_hooks(
&doc,
&sel,
*inner_open,
DEFAULT_PAIRS,
&expected_doc,
&expected_sel,
);
}
}
}
/// []word -> insert ( -> ([]word
#[test]
fn test_insert_open_before_non_pair() {
test_hooks_with_pairs(
&Rope::from("word"),
&Selection::single(1, 0),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, _| format!("{}word", open),
&Selection::single(2, 1),
)
}
/// [wor]d -> insert ( -> ([wor]d
#[test]
fn test_insert_open_with_selection() {
test_hooks_with_pairs(
&Rope::from("word"),
&Selection::single(3, 0),
DEFAULT_PAIRS,
DEFAULT_PAIRS,
|open, _| format!("{}word", open),
&Selection::single(4, 1),
)
}
/// [wor]d -> append ) -> [wor)]d
#[test]
fn test_append_close_inside_non_pair_with_selection() {
let sel = Selection::single(0, 4);
let expected_sel = Selection::single(0, 5);
for (_, close) in DEFAULT_PAIRS {
let doc = Rope::from("word");
let expected_doc = Rope::from(format!("wor{}d", close));
test_hooks(
&doc,
&sel,
*close,
DEFAULT_PAIRS,
&expected_doc,
&expected_sel,
);
}
}
/// foo[ wor]d -> insert ( -> foo([) wor]d
#[test]
fn test_insert_open_trailing_word_with_selection() {
test_hooks_with_pairs(
&Rope::from("foo word"),
&Selection::single(7, 3),
differing_pairs(),
DEFAULT_PAIRS,
|open, close| format!("foo{}{} word", open, close),
&Selection::single(9, 4),
)
}
/// foo([) wor]d -> insert ) -> foo()[ wor]d
#[test]
fn test_insert_close_inside_pair_trailing_word_with_selection() {
for (open, close) in differing_pairs() {
test_hooks(
&Rope::from(format!("foo{}{} word{}", open, close, LINE_END)),
&Selection::single(9, 4),
*close,
DEFAULT_PAIRS,
&Rope::from(format!("foo{}{} word{}", open, close, LINE_END)),
&Selection::single(9, 5),
)
}
}
/// we want pairs that are *not* the same char to be inserted after
/// a non-pair char, for cases like functions, but for pairs that are
/// the same char, we want to *not* insert a pair to handle cases like "I'm"
///
/// word[] -> insert ( -> word([])
/// word[] -> insert ' -> word'[]
#[test]
fn test_insert_open_after_non_pair() {
let doc = Rope::from(format!("word{}", LINE_END));
let sel = Selection::single(5, 4);
let expected_sel = Selection::single(6, 5);
test_hooks_with_pairs(
&doc,
&sel,
differing_pairs(),
DEFAULT_PAIRS,
|open, close| format!("word{}{}{}", open, close, LINE_END),
&expected_sel,
);
test_hooks_with_pairs(
&doc,
&sel,
matching_pairs(),
DEFAULT_PAIRS,
|open, _| format!("word{}{}", open, LINE_END),
&expected_sel,
);
}
#[test]
fn test_configured_pairs() {
let test_pairs = &[('`', ':'), ('+', '-')];
test_hooks_with_pairs(
&Rope::from(LINE_END),
&Selection::single(1, 0),
test_pairs,
test_pairs,
|open, close| format!("{}{}{}", open, close, LINE_END),
&Selection::single(2, 1),
);
let doc = Rope::from(format!("foo`: word{}", LINE_END));
test_hooks(
&doc,
&Selection::single(9, 4),
':',
test_pairs,
&doc,
&Selection::single(9, 5),
)
}
}

@ -100,43 +100,41 @@ mod test {
#[test] #[test]
fn test_find_line_comment() { fn test_find_line_comment() {
use crate::State;
// four lines, two space indented, except for line 1 which is blank. // four lines, two space indented, except for line 1 which is blank.
let doc = Rope::from(" 1\n\n 2\n 3"); let mut doc = Rope::from(" 1\n\n 2\n 3");
let mut state = State::new(doc);
// select whole document // select whole document
state.selection = Selection::single(0, state.doc.len_chars() - 1); let mut selection = Selection::single(0, doc.len_chars() - 1);
let text = state.doc.slice(..); let text = doc.slice(..);
let res = find_line_comment("//", text, 0..3); let res = find_line_comment("//", text, 0..3);
// (commented = true, to_change = [line 0, line 2], min = col 2, margin = 1) // (commented = true, to_change = [line 0, line 2], min = col 2, margin = 1)
assert_eq!(res, (false, vec![0, 2], 2, 1)); assert_eq!(res, (false, vec![0, 2], 2, 1));
// comment // comment
let transaction = toggle_line_comments(&state.doc, &state.selection, None); let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut state.doc); transaction.apply(&mut doc);
state.selection = state.selection.map(transaction.changes()); selection = selection.map(transaction.changes());
assert_eq!(state.doc, " // 1\n\n // 2\n // 3"); assert_eq!(doc, " // 1\n\n // 2\n // 3");
// uncomment // uncomment
let transaction = toggle_line_comments(&state.doc, &state.selection, None); let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut state.doc); transaction.apply(&mut doc);
state.selection = state.selection.map(transaction.changes()); selection = selection.map(transaction.changes());
assert_eq!(state.doc, " 1\n\n 2\n 3"); assert_eq!(doc, " 1\n\n 2\n 3");
assert!(selection.len() == 1); // to ignore the selection unused warning
// 0 margin comments // 0 margin comments
state.doc = Rope::from(" //1\n\n //2\n //3"); doc = Rope::from(" //1\n\n //2\n //3");
// reset the selection. // reset the selection.
state.selection = Selection::single(0, state.doc.len_chars() - 1); selection = Selection::single(0, doc.len_chars() - 1);
let transaction = toggle_line_comments(&state.doc, &state.selection, None); let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut state.doc); transaction.apply(&mut doc);
state.selection = state.selection.map(transaction.changes()); selection = selection.map(transaction.changes());
assert_eq!(state.doc, " 1\n\n 2\n 3"); assert_eq!(doc, " 1\n\n 2\n 3");
assert!(selection.len() == 1); // to ignore the selection unused warning
// TODO: account for uncommenting with uneven comment indentation // TODO: account for uncommenting with uneven comment indentation
} }

@ -29,6 +29,12 @@ pub enum NumberOrString {
String(String), String(String),
} }
#[derive(Debug, Clone)]
pub enum DiagnosticTag {
Unnecessary,
Deprecated,
}
/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html) /// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Diagnostic { pub struct Diagnostic {
@ -37,4 +43,7 @@ pub struct Diagnostic {
pub message: String, pub message: String,
pub severity: Option<Severity>, pub severity: Option<Severity>,
pub code: Option<NumberOrString>, pub code: Option<NumberOrString>,
pub tags: Vec<DiagnosticTag>,
pub source: Option<String>,
pub data: Option<serde_json::Value>,
} }

@ -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 use imara_diff::intern::InternedInput;
/// the steps required to get from `old` to `new`. use imara_diff::Algorithm;
pub fn compare_ropes(old: &Rope, new: &Rope) -> Transaction { use ropey::RopeSlice;
// `similar` only works on contiguous data, so a `Rope` has
// to be temporarily converted into a `String`. use crate::{ChangeSet, Rope, Tendril, Transaction};
let old_converted = old.to_string();
let new_converted = new.to_string(); /// A `imara_diff::Sink` that builds a `ChangeSet` for a character diff of a hunk
struct CharChangeSetBuilder<'a> {
// A timeout is set so after 1 seconds, the algorithm will start res: &'a mut ChangeSet,
// approximating. This is especially important for big `Rope`s or hunk: &'a InternedInput<char>,
// `Rope`s that are extremely dissimilar to each other. pos: u32,
let mut config = similar::TextDiff::configure(); }
config.timeout(std::time::Duration::from_secs(1));
impl imara_diff::Sink for CharChangeSetBuilder<'_> {
let diff = config.diff_chars(&old_converted, &new_converted); type Out = ();
fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
// The current position of the change needs to be tracked to self.res.retain((before.start - self.pos) as usize);
// construct the `Change`s. self.res.delete(before.len());
let mut pos = 0; self.pos = before.end;
Transaction::change(
old, let res = self.hunk.after[after.start as usize..after.end as usize]
diff.ops() .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<RopeSlice<'a>>,
current_hunk: InternedInput<char>,
pos: u32,
}
impl imara_diff::Sink for LineChangeSetBuilder<'_> {
type Out = ChangeSet;
fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
let len = self.file.before[self.pos as usize..before.start as usize]
.iter() .iter()
.map(|op| op.as_tag_tuple()) .map(|&it| self.file.interner[it].len_chars())
.filter_map(|(tag, old_range, new_range)| { .sum();
// `old_pos..pos` is equivalent to `start..end` for where self.res.retain(len);
// the change should be applied. self.pos = before.end;
let old_pos = pos;
pos += old_range.end - old_range.start; // do not perform diffs on large hunks
let len_before = before.end - before.start;
match tag { let len_after = after.end - after.start;
// Semantically, inserts and replacements are the same thing.
similar::DiffTag::Insert | similar::DiffTag::Replace => { // Pure insertions/removals do not require a character diff.
// This is the text from the `new` rope that should be // Very large changes are ignored because their character diff is expensive to compute
// inserted into `old`. // TODO adjust heuristic to detect large changes?
let text: &str = { if len_before == 0
let start = new.char_to_byte(new_range.start); || len_after == 0
let end = new.char_to_byte(new_range.end); || len_after > 5 * len_before
&new_converted[start..end] || 5 * len_after < len_before && len_before > 10
}; || len_before + len_after > 200
Some((old_pos, pos, Some(text.into()))) {
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)), } else if after.start == 0 {
similar::DiffTag::Equal => None, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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! { quickcheck::quickcheck! {
fn test_compare_ropes(a: String, b: String) -> bool { fn test_compare_ropes(a: String, b: String) -> bool {
let mut old = Rope::from(a); let mut old = Rope::from(a);
@ -61,4 +197,25 @@ mod tests {
old == new 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", "");
}
} }

@ -1,9 +1,15 @@
use crate::{Assoc, ChangeSet, Range, Rope, State, Transaction}; use crate::{Assoc, ChangeSet, Range, Rope, Selection, Transaction};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct State {
pub doc: Rope,
pub selection: Selection,
}
/// Stores the history of changes to a buffer. /// Stores the history of changes to a buffer.
/// ///
/// Currently the history is represented as a vector of revisions. The vector /// Currently the history is represented as a vector of revisions. The vector
@ -48,7 +54,7 @@ pub struct History {
} }
/// A single point in history. See [History] for more information. /// A single point in history. See [History] for more information.
#[derive(Debug)] #[derive(Debug, Clone)]
struct Revision { struct Revision {
parent: usize, parent: usize,
last_child: Option<NonZeroUsize>, last_child: Option<NonZeroUsize>,
@ -113,6 +119,21 @@ impl History {
self.current == 0 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<Transaction> {
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. /// Undo the last edit.
pub fn undo(&mut self) -> Option<&Transaction> { pub fn undo(&mut self) -> Option<&Transaction> {
if self.at_root() { if self.at_root() {
@ -282,7 +303,7 @@ impl History {
} }
/// Whether to undo by a number of edits or a duration of time. /// Whether to undo by a number of edits or a duration of time.
#[derive(Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum UndoKind { pub enum UndoKind {
Steps(usize), Steps(usize),
TimePeriod(std::time::Duration), TimePeriod(std::time::Duration),
@ -366,12 +387,16 @@ impl std::str::FromStr for UndoKind {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::Selection;
#[test] #[test]
fn test_undo_redo() { fn test_undo_redo() {
let mut history = History::default(); let mut history = History::default();
let doc = Rope::from("hello"); let doc = Rope::from("hello");
let mut state = State::new(doc); let mut state = State {
doc,
selection: Selection::point(0),
};
let transaction1 = let transaction1 =
Transaction::change(&state.doc, vec![(5, 5, Some(" world!".into()))].into_iter()); Transaction::change(&state.doc, vec![(5, 5, Some(" world!".into()))].into_iter());
@ -420,7 +445,10 @@ mod test {
fn test_earlier_later() { fn test_earlier_later() {
let mut history = History::default(); let mut history = History::default();
let doc = Rope::from("a\n"); let doc = Rope::from("a\n");
let mut state = State::new(doc); let mut state = State {
doc,
selection: Selection::point(0),
};
fn undo(history: &mut History, state: &mut State) { fn undo(history: &mut History, state: &mut State) {
if let Some(transaction) = history.undo() { if let Some(transaction) = history.undo() {

@ -74,12 +74,12 @@ impl DateTimeIncrementor {
(true, false) => { (true, false) => {
let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?; let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?;
date.and_hms(0, 0, 0) date.and_hms_opt(0, 0, 0).unwrap()
} }
(false, true) => { (false, true) => {
let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?; let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?;
NaiveDate::from_ymd(0, 1, 1).and_time(time) NaiveDate::from_ymd_opt(0, 1, 1).unwrap().and_time(time)
} }
(false, false) => return None, (false, false) => return None,
}; };
@ -312,10 +312,10 @@ fn ndays_in_month(year: i32, month: u32) -> u32 {
} else { } else {
(year, month + 1) (year, month + 1)
}; };
let d = NaiveDate::from_ymd(y, m, 1); let d = NaiveDate::from_ymd_opt(y, m, 1).unwrap();
// ...is preceded by the last day of the original month. // ...is preceded by the last day of the original month.
d.pred().day() d.pred_opt().unwrap().day()
} }
fn add_months(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> { fn add_months(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> {
@ -334,7 +334,7 @@ fn add_months(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> {
let day = cmp::min(date_time.day(), ndays_in_month(year, month)); let day = cmp::min(date_time.day(), ndays_in_month(year, month));
Some(NaiveDate::from_ymd(year, month, day).and_time(date_time.time())) NaiveDate::from_ymd_opt(year, month, day).map(|date| date.and_time(date_time.time()))
} }
fn add_years(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> { fn add_years(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> {
@ -342,8 +342,8 @@ fn add_years(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> {
let ndays = ndays_in_month(year, date_time.month()); let ndays = ndays_in_month(year, date_time.month());
if date_time.day() > ndays { if date_time.day() > ndays {
let d = NaiveDate::from_ymd(year, date_time.month(), ndays); NaiveDate::from_ymd_opt(year, date_time.month(), ndays)
Some(d.succ().and_time(date_time.time())) .and_then(|date| date.succ_opt().map(|date| date.and_time(date_time.time())))
} else { } else {
date_time.with_year(year) date_time.with_year(year)
} }

@ -110,8 +110,8 @@ impl<'a> Increment for NumberIncrementor<'a> {
let (lower_count, upper_count): (usize, usize) = let (lower_count, upper_count): (usize, usize) =
old_text.chars().skip(2).fold((0, 0), |(lower, upper), c| { old_text.chars().skip(2).fold((0, 0), |(lower, upper), c| {
( (
lower + c.is_ascii_lowercase().then(|| 1).unwrap_or(0), lower + usize::from(c.is_ascii_lowercase()),
upper + c.is_ascii_uppercase().then(|| 1).unwrap_or(0), upper + usize::from(c.is_ascii_uppercase()),
) )
}); });
if upper_count > lower_count { if upper_count > lower_count {

@ -192,13 +192,15 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize) -> usize {
/// Computes for node and all ancestors whether they are the first node on their line. /// Computes for node and all ancestors whether they are the first node on their line.
/// The first entry in the return value represents the root node, the last one the node itself /// The first entry in the return value represents the root node, the last one the node itself
fn get_first_in_line(mut node: Node, byte_pos: usize, new_line: bool) -> Vec<bool> { fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bool> {
let mut first_in_line = Vec::new(); let mut first_in_line = Vec::new();
loop { loop {
if let Some(prev) = node.prev_sibling() { if let Some(prev) = node.prev_sibling() {
// If we insert a new line, the first node at/after the cursor is considered to be the first in its line // If we insert a new line, the first node at/after the cursor is considered to be the first in its line
let first = prev.end_position().row != node.start_position().row let first = prev.end_position().row != node.start_position().row
|| (new_line && node.start_byte() >= byte_pos && prev.start_byte() < byte_pos); || new_line_byte_pos.map_or(false, |byte_pos| {
node.start_byte() >= byte_pos && prev.start_byte() < byte_pos
});
first_in_line.push(Some(first)); first_in_line.push(Some(first));
} else { } else {
// Nodes that have no previous siblings are first in their line if and only if their parent is // Nodes that have no previous siblings are first in their line if and only if their parent is
@ -298,8 +300,21 @@ enum IndentScope {
Tail, Tail,
} }
/// Execute the indent query. /// A capture from the indent query which does not define an indent but extends
/// Returns for each node (identified by its id) a list of indent captures for that node. /// the range of a node. This is used before the indent is calculated.
enum ExtendCapture {
Extend,
PreventOnce,
}
/// The result of running a tree-sitter indent query. This stores for
/// each node (identified by its ID) the relevant captures (already filtered
/// by predicates).
struct IndentQueryResult {
indent_captures: HashMap<usize, Vec<IndentCapture>>,
extend_captures: HashMap<usize, Vec<ExtendCapture>>,
}
fn query_indents( fn query_indents(
query: &Query, query: &Query,
syntax: &Syntax, syntax: &Syntax,
@ -309,8 +324,9 @@ fn query_indents(
// Position of the (optional) newly inserted line break. // Position of the (optional) newly inserted line break.
// Given as (line, byte_pos) // Given as (line, byte_pos)
new_line_break: Option<(usize, usize)>, new_line_break: Option<(usize, usize)>,
) -> HashMap<usize, Vec<IndentCapture>> { ) -> IndentQueryResult {
let mut indent_captures: HashMap<usize, Vec<IndentCapture>> = HashMap::new(); let mut indent_captures: HashMap<usize, Vec<IndentCapture>> = HashMap::new();
let mut extend_captures: HashMap<usize, Vec<ExtendCapture>> = HashMap::new();
cursor.set_byte_range(range); cursor.set_byte_range(range);
// Iterate over all captures from the query // Iterate over all captures from the query
for m in cursor.matches(query, syntax.tree().root_node(), RopeProvider(text)) { for m in cursor.matches(query, syntax.tree().root_node(), RopeProvider(text)) {
@ -374,10 +390,24 @@ fn query_indents(
continue; continue;
} }
for capture in m.captures { for capture in m.captures {
let capture_type = query.capture_names()[capture.index as usize].as_str(); let capture_name = query.capture_names()[capture.index as usize].as_str();
let capture_type = match capture_type { let capture_type = match capture_name {
"indent" => IndentCaptureType::Indent, "indent" => IndentCaptureType::Indent,
"outdent" => IndentCaptureType::Outdent, "outdent" => IndentCaptureType::Outdent,
"extend" => {
extend_captures
.entry(capture.node.id())
.or_insert_with(|| Vec::with_capacity(1))
.push(ExtendCapture::Extend);
continue;
}
"extend.prevent-once" => {
extend_captures
.entry(capture.node.id())
.or_insert_with(|| Vec::with_capacity(1))
.push(ExtendCapture::PreventOnce);
continue;
}
_ => { _ => {
// Ignore any unknown captures (these may be needed for predicates such as #match?) // Ignore any unknown captures (these may be needed for predicates such as #match?)
continue; continue;
@ -420,7 +450,74 @@ fn query_indents(
.push(indent_capture); .push(indent_capture);
} }
} }
indent_captures IndentQueryResult {
indent_captures,
extend_captures,
}
}
/// Handle extend queries. deepest_preceding is the deepest descendant of node that directly precedes the cursor position.
/// Any ancestor of deepest_preceding which is also a descendant of node may be "extended". In that case, node will be updated,
/// so that the indent computation starts with the correct syntax node.
fn extend_nodes<'a>(
node: &mut Node<'a>,
mut deepest_preceding: Node<'a>,
extend_captures: &HashMap<usize, Vec<ExtendCapture>>,
text: RopeSlice,
line: usize,
tab_width: usize,
) {
let mut stop_extend = false;
while deepest_preceding != *node {
let mut extend_node = false;
// This will be set to true if this node is captured, regardless of whether
// it actually will be extended (e.g. because the cursor isn't indented
// more than the node).
let mut node_captured = false;
if let Some(captures) = extend_captures.get(&deepest_preceding.id()) {
for capture in captures {
match capture {
ExtendCapture::PreventOnce => {
stop_extend = true;
}
ExtendCapture::Extend => {
node_captured = true;
// We extend the node if
// - the cursor is on the same line as the end of the node OR
// - the line that the cursor is on is more indented than the
// first line of the node
if deepest_preceding.end_position().row == line {
extend_node = true;
} else {
let cursor_indent = indent_level_for_line(text.line(line), tab_width);
let node_indent = indent_level_for_line(
text.line(deepest_preceding.start_position().row),
tab_width,
);
if cursor_indent > node_indent {
extend_node = true;
}
}
}
}
}
}
// If we encountered some `StopExtend` capture before, we don't
// extend the node even if we otherwise would
if node_captured && stop_extend {
stop_extend = false;
} else if extend_node && !stop_extend {
*node = deepest_preceding;
break;
}
// If the tree contains a syntax error, `deepest_preceding` may not
// have a parent despite being a descendant of `node`.
deepest_preceding = match deepest_preceding.parent() {
Some(parent) => parent,
None => return,
}
}
} }
/// Use the syntax tree to determine the indentation for a given position. /// Use the syntax tree to determine the indentation for a given position.
@ -459,40 +556,75 @@ fn query_indents(
/// }, /// },
/// ); /// );
/// ``` /// ```
#[allow(clippy::too_many_arguments)]
pub fn treesitter_indent_for_pos( pub fn treesitter_indent_for_pos(
query: &Query, query: &Query,
syntax: &Syntax, syntax: &Syntax,
indent_style: &IndentStyle, indent_style: &IndentStyle,
tab_width: usize,
text: RopeSlice, text: RopeSlice,
line: usize, line: usize,
pos: usize, pos: usize,
new_line: bool, new_line: bool,
) -> Option<String> { ) -> Option<String> {
let byte_pos = text.char_to_byte(pos); let byte_pos = text.char_to_byte(pos);
// The innermost tree-sitter node which is considered for the indent
// computation. It may change if some predeceding node is extended
let mut node = syntax let mut node = syntax
.tree() .tree()
.root_node() .root_node()
.descendant_for_byte_range(byte_pos, byte_pos)?; .descendant_for_byte_range(byte_pos, byte_pos)?;
let mut first_in_line = get_first_in_line(node, byte_pos, new_line); let (query_result, deepest_preceding) = {
let new_line_break = if new_line { // The query range should intersect with all nodes directly preceding
Some((line, byte_pos)) // the position of the indent query in case one of them is extended.
} else { let mut deepest_preceding = None; // The deepest node preceding the indent query position
None let mut tree_cursor = node.walk();
for child in node.children(&mut tree_cursor) {
if child.byte_range().end <= byte_pos {
deepest_preceding = Some(child);
}
}
deepest_preceding = deepest_preceding.map(|mut prec| {
// Get the deepest directly preceding node
while prec.child_count() > 0 {
prec = prec.child(prec.child_count() - 1).unwrap();
}
prec
});
let query_range = deepest_preceding
.map(|prec| prec.byte_range().end - 1..byte_pos + 1)
.unwrap_or(byte_pos..byte_pos + 1);
crate::syntax::PARSER.with(|ts_parser| {
let mut ts_parser = ts_parser.borrow_mut();
let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new);
let query_result = query_indents(
query,
syntax,
&mut cursor,
text,
query_range,
new_line.then(|| (line, byte_pos)),
);
ts_parser.cursors.push(cursor);
(query_result, deepest_preceding)
})
}; };
let query_result = crate::syntax::PARSER.with(|ts_parser| { let indent_captures = query_result.indent_captures;
let mut ts_parser = ts_parser.borrow_mut(); let extend_captures = query_result.extend_captures;
let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new);
let query_result = query_indents( // Check for extend captures, potentially changing the node that the indent calculation starts with
query, if let Some(deepest_preceding) = deepest_preceding {
syntax, extend_nodes(
&mut cursor, &mut node,
deepest_preceding,
&extend_captures,
text, text,
byte_pos..byte_pos + 1, line,
new_line_break, tab_width,
); );
ts_parser.cursors.push(cursor); }
query_result let mut first_in_line = get_first_in_line(node, new_line.then(|| byte_pos));
});
let mut result = Indentation::default(); let mut result = Indentation::default();
// We always keep track of all the indent changes on one line, in order to only indent once // We always keep track of all the indent changes on one line, in order to only indent once
@ -504,7 +636,7 @@ pub fn treesitter_indent_for_pos(
// one entry for each ancestor of the node (which is what we iterate over) // one entry for each ancestor of the node (which is what we iterate over)
let is_first = *first_in_line.last().unwrap(); let is_first = *first_in_line.last().unwrap();
// Apply all indent definitions for this node // Apply all indent definitions for this node
if let Some(definitions) = query_result.get(&node.id()) { if let Some(definitions) = indent_captures.get(&node.id()) {
for definition in definitions { for definition in definitions {
match definition.scope { match definition.scope {
IndentScope::All => { IndentScope::All => {
@ -550,7 +682,13 @@ pub fn treesitter_indent_for_pos(
node = parent; node = parent;
first_in_line.pop(); first_in_line.pop();
} else { } else {
result.add_line(&indent_for_line_below); // Only add the indentation for the line below if that line
// is not after the line that the indentation is calculated for.
if (node.start_position().row < line)
|| (new_line && node.start_position().row == line && node.start_byte() < byte_pos)
{
result.add_line(&indent_for_line_below);
}
result.add_line(&indent_for_line); result.add_line(&indent_for_line);
break; break;
} }
@ -579,6 +717,7 @@ pub fn indent_for_newline(
query, query,
syntax, syntax,
indent_style, indent_style,
tab_width,
text, text,
line_before, line_before,
line_before_end_pos, line_before_end_pos,

@ -21,7 +21,6 @@ pub mod register;
pub mod search; pub mod search;
pub mod selection; pub mod selection;
pub mod shellwords; pub mod shellwords;
mod state;
pub mod surround; pub mod surround;
pub mod syntax; pub mod syntax;
pub mod test; pub mod test;
@ -46,13 +45,45 @@ pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option<usize> {
/// * Git repository root if no marker detected /// * Git repository root if no marker detected
/// * Top-most folder containing a root marker if not git repository detected /// * Top-most folder containing a root marker if not git repository detected
/// * Current working directory as fallback /// * Current working directory as fallback
pub fn find_root(root: Option<&str>, root_markers: &[String]) -> Option<std::path::PathBuf> { pub fn find_root(root: Option<&str>, root_markers: &[String]) -> std::path::PathBuf {
helix_loader::find_root_impl(root, root_markers) let current_dir = std::env::current_dir().expect("unable to determine current directory");
.first()
.cloned() let root = match root {
Some(root) => {
let root = std::path::Path::new(root);
if root.is_absolute() {
root.to_path_buf()
} else {
current_dir.join(root)
}
}
None => current_dir.clone(),
};
let mut top_marker = None;
for ancestor in root.ancestors() {
if root_markers
.iter()
.any(|marker| ancestor.join(marker).exists())
{
top_marker = Some(ancestor);
}
if ancestor.join(".git").exists() {
// Top marker is repo root if not root marker was detected yet
if top_marker.is_none() {
top_marker = Some(ancestor);
}
// Don't go higher than repo if we're in one
break;
}
}
// Return the found top marker or the current_dir as fallback
top_marker.map_or(current_dir, |a| a.to_path_buf())
} }
pub use ropey::{str_utils, Rope, RopeBuilder, RopeSlice}; pub use ropey::{self, str_utils, Rope, RopeBuilder, RopeSlice};
// pub use tendril::StrTendril as Tendril; // pub use tendril::StrTendril as Tendril;
pub use smartstring::SmartString; pub use smartstring::SmartString;
@ -71,7 +102,6 @@ pub use smallvec::{smallvec, SmallVec};
pub use syntax::Syntax; pub use syntax::Syntax;
pub use diagnostic::Diagnostic; pub use diagnostic::Diagnostic;
pub use state::State;
pub use line_ending::{LineEnding, DEFAULT_LINE_ENDING}; pub use line_ending::{LineEnding, DEFAULT_LINE_ENDING};
pub use transaction::{Assoc, Change, ChangeSet, Operation, Transaction}; pub use transaction::{Assoc, Change, ChangeSet, Operation, Transaction};

@ -6,7 +6,7 @@ pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::Crlf;
pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::LF; pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::LF;
/// Represents one of the valid Unicode line endings. /// Represents one of the valid Unicode line endings.
#[derive(PartialEq, Copy, Clone, Debug)] #[derive(PartialEq, Eq, Copy, Clone, Debug)]
pub enum LineEnding { pub enum LineEnding {
Crlf, // CarriageReturn followed by LineFeed Crlf, // CarriageReturn followed by LineFeed
LF, // U+000A -- LineFeed LF, // U+000A -- LineFeed

@ -389,6 +389,8 @@ fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> boo
} }
} }
/// Finds the range of the next or previous textobject in the syntax sub-tree of `node`.
/// Returns the range in the forwards direction.
pub fn goto_treesitter_object( pub fn goto_treesitter_object(
slice: RopeSlice, slice: RopeSlice,
range: Range, range: Range,
@ -419,8 +421,8 @@ pub fn goto_treesitter_object(
.filter(|n| n.start_byte() > byte_pos) .filter(|n| n.start_byte() > byte_pos)
.min_by_key(|n| n.start_byte())?, .min_by_key(|n| n.start_byte())?,
Direction::Backward => nodes Direction::Backward => nodes
.filter(|n| n.start_byte() < byte_pos) .filter(|n| n.end_byte() < byte_pos)
.max_by_key(|n| n.start_byte())?, .max_by_key(|n| n.end_byte())?,
}; };
let len = slice.len_bytes(); let len = slice.len_bytes();
@ -434,7 +436,7 @@ pub fn goto_treesitter_object(
let end_char = slice.byte_to_char(end_byte); let end_char = slice.byte_to_char(end_byte);
// head of range should be at beginning // head of range should be at beginning
Some(Range::new(end_char, start_char)) Some(Range::new(start_char, end_char))
}; };
(0..count).fold(range, |range, _| get_range(range).unwrap_or(range)) (0..count).fold(range, |range, _| get_range(range).unwrap_or(range))
} }

@ -15,11 +15,7 @@ impl Register {
} }
pub fn new_with_values(name: char, values: Vec<String>) -> Self { pub fn new_with_values(name: char, values: Vec<String>) -> Self {
if name == '_' { Self { name, values }
Self::new(name)
} else {
Self { name, values }
}
} }
pub const fn name(&self) -> char { pub const fn name(&self) -> char {
@ -31,15 +27,11 @@ impl Register {
} }
pub fn write(&mut self, values: Vec<String>) { pub fn write(&mut self, values: Vec<String>) {
if self.name != '_' { self.values = values;
self.values = values;
}
} }
pub fn push(&mut self, value: String) { 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) self.inner.get(&name)
} }
pub fn get_mut(&mut self, name: char) -> &mut Register { pub fn read(&self, name: char) -> Option<&[String]> {
self.inner self.get(name).map(|reg| reg.read())
.entry(name)
.or_insert_with(|| Register::new(name))
} }
pub fn write(&mut self, name: char, values: Vec<String>) { pub fn write(&mut self, name: char, values: Vec<String>) {
self.inner if name != '_' {
.insert(name, Register::new_with_values(name, values)); self.inner
.insert(name, Register::new_with_values(name, values));
}
} }
pub fn read(&self, name: char) -> Option<&[String]> { pub fn push(&mut self, name: char, value: String) {
self.get(name).map(|reg| reg.read()) 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> { pub fn first(&self, name: char) -> Option<&String> {

@ -122,7 +122,7 @@ impl Range {
} }
} }
// flips the direction of the selection /// Flips the direction of the selection
pub fn flip(&self) -> Self { pub fn flip(&self) -> Self {
Self { Self {
anchor: self.head, anchor: self.head,
@ -131,6 +131,16 @@ impl Range {
} }
} }
/// Returns the selection if it goes in the direction of `direction`,
/// flipping the selection otherwise.
pub fn with_direction(self, direction: Direction) -> Self {
if self.direction() == direction {
self
} else {
self.flip()
}
}
/// Check two ranges for overlap. /// Check two ranges for overlap.
#[must_use] #[must_use]
pub fn overlaps(&self, other: &Self) -> bool { pub fn overlaps(&self, other: &Self) -> bool {
@ -485,28 +495,53 @@ impl Selection {
/// Normalizes a `Selection`. /// Normalizes a `Selection`.
fn normalize(mut self) -> Self { 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.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 self.primary_index = self
.ranges .ranges
.iter() .iter()
.position(|&range| range == primary) .position(|&range| range == primary)
.unwrap(); .unwrap();
let mut prev_i = 0; self
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]); // 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 { } else {
prev_i += 1; false
self.ranges[prev_i] = self.ranges[i];
}
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 self
} }
@ -1122,6 +1157,52 @@ mod test {
&["", "abcd", "efg", "rs", "xyz"] &["", "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] #[test]
fn test_selection_contains() { fn test_selection_contains() {
fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool { fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool {

@ -1,109 +1,198 @@
use std::borrow::Cow; use std::borrow::Cow;
/// Get the vec of escaped / quoted / doublequoted filenames from the input str /// Auto escape for shellwords usage.
pub fn shellwords(input: &str) -> Vec<Cow<'_, str>> { pub fn escape(input: Cow<str>) -> Cow<str> {
enum State { if !input.chars().any(|x| x.is_ascii_whitespace()) {
Normal, input
NormalEscaped, } else if cfg!(unix) {
Quoted, Cow::Owned(input.chars().fold(String::new(), |mut buf, c| {
QuoteEscaped, if c.is_ascii_whitespace() {
Dquoted, buf.push('\\');
DquoteEscaped, }
buf.push(c);
buf
}))
} else {
Cow::Owned(format!("\"{}\"", input))
} }
}
enum State {
OnWhitespace,
Unquoted,
UnquotedEscaped,
Quoted,
QuoteEscaped,
Dquoted,
DquoteEscaped,
}
use State::*; pub struct Shellwords<'a> {
state: State,
/// Shellwords where whitespace and escapes has been resolved.
words: Vec<Cow<'a, str>>,
/// The parts of the input that are divided into shellwords. This can be
/// used to retrieve the original text for a given word by looking up the
/// same index in the Vec as the word in `words`.
parts: Vec<&'a str>,
}
let mut state = Normal; impl<'a> From<&'a str> for Shellwords<'a> {
let mut args: Vec<Cow<str>> = Vec::new(); fn from(input: &'a str) -> Self {
let mut escaped = String::with_capacity(input.len()); use State::*;
let mut start = 0; let mut state = Unquoted;
let mut end = 0; 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() { let mut part_start = 0;
state = match state { let mut unescaped_start = 0;
Normal => match c { let mut end = 0;
'\\' => {
if cfg!(unix) { for (i, c) in input.char_indices() {
escaped.push_str(&input[start..i]); state = match state {
start = i + 1; OnWhitespace => match c {
NormalEscaped '"' => {
} else { end = i;
Normal Dquoted
} }
} '\'' => {
'"' => { end = i;
end = i;
Dquoted
}
'\'' => {
end = i;
Quoted
}
c if c.is_ascii_whitespace() => {
end = i;
Normal
}
_ => Normal,
},
NormalEscaped => Normal,
Quoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[start..i]);
start = i + 1;
QuoteEscaped
} else {
Quoted Quoted
} }
} '\\' => {
'\'' => { if cfg!(unix) {
end = i; escaped.push_str(&input[unescaped_start..i]);
Normal unescaped_start = i + 1;
} UnquotedEscaped
_ => Quoted, } else {
}, OnWhitespace
QuoteEscaped => Quoted, }
Dquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[start..i]);
start = i + 1;
DquoteEscaped
} else {
Dquoted
} }
} c if c.is_ascii_whitespace() => {
'"' => { end = i;
end = i; OnWhitespace
Normal }
} _ => Unquoted,
_ => Dquoted, },
}, Unquoted => match c {
DquoteEscaped => Dquoted, '\\' => {
}; if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
Unquoted
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
UnquotedEscaped => Unquoted,
Quoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
QuoteEscaped
} else {
Quoted
}
}
'\'' => {
end = i;
OnWhitespace
}
_ => Quoted,
},
QuoteEscaped => Quoted,
Dquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
DquoteEscaped
} else {
Dquoted
}
}
'"' => {
end = i;
OnWhitespace
}
_ => Dquoted,
},
DquoteEscaped => Dquoted,
};
if i >= input.len() - 1 && end == 0 { if i >= input.len() - 1 && end == 0 {
end = i + 1; end = i + 1;
} }
if end > 0 { if end > 0 {
let esc_trim = escaped.trim(); let esc_trim = escaped.trim();
let inp = &input[start..end]; let inp = &input[unescaped_start..end];
if !(esc_trim.is_empty() && inp.trim().is_empty()) { if !(esc_trim.is_empty() && inp.trim().is_empty()) {
if esc_trim.is_empty() { if esc_trim.is_empty() {
args.push(inp.into()); words.push(inp.into());
} else { parts.push(inp);
args.push([escaped, inp.into()].concat().into()); } else {
escaped = "".to_string(); words.push([escaped, inp.into()].concat().into());
parts.push(&input[part_start..end]);
escaped = "".to_string();
}
} }
unescaped_start = i + 1;
part_start = i + 1;
end = 0;
} }
start = i + 1;
end = 0;
} }
debug_assert!(words.len() == parts.len());
Self {
state,
words,
parts,
}
}
}
impl<'a> Shellwords<'a> {
/// Checks that the input ends with a whitespace character which is not escaped.
///
/// # Examples
///
/// ```rust
/// use helix_core::shellwords::Shellwords;
/// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
/// #[cfg(unix)]
/// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false);
/// #[cfg(unix)]
/// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
/// ```
pub fn ends_with_whitespace(&self) -> bool {
matches!(self.state, State::OnWhitespace)
}
/// Returns the list of shellwords calculated from the input string.
pub fn words(&self) -> &[Cow<'a, str>] {
&self.words
}
/// Returns a list of strings which correspond to [`Self::words`] but represent the original
/// text in the input string - including escape characters - without separating whitespace.
pub fn parts(&self) -> &[&'a str] {
&self.parts
} }
args
} }
#[cfg(test)] #[cfg(test)]
@ -114,7 +203,8 @@ mod test {
#[cfg(windows)] #[cfg(windows)]
fn test_normal() { fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; 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![ let expected = vec![
Cow::from(":o"), Cow::from(":o"),
Cow::from("single_word"), Cow::from("single_word"),
@ -132,7 +222,8 @@ mod test {
#[cfg(unix)] #[cfg(unix)]
fn test_normal() { fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; 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![ let expected = vec![
Cow::from(":o"), Cow::from(":o"),
Cow::from("single_word"), Cow::from("single_word"),
@ -149,7 +240,8 @@ mod test {
fn test_quoted() { fn test_quoted() {
let quoted = let quoted =
r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; 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![ let expected = vec![
Cow::from(":o"), Cow::from(":o"),
Cow::from("single_word"), Cow::from("single_word"),
@ -164,7 +256,8 @@ mod test {
#[cfg(unix)] #[cfg(unix)]
fn test_dquoted() { fn test_dquoted() {
let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; 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![ let expected = vec![
Cow::from(":o"), Cow::from(":o"),
Cow::from("single_word"), Cow::from("single_word"),
@ -179,7 +272,8 @@ mod test {
#[cfg(unix)] #[cfg(unix)]
fn test_mixed() { fn test_mixed() {
let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; 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![ let expected = vec![
Cow::from(":o"), Cow::from(":o"),
Cow::from("single_word"), Cow::from("single_word"),
@ -195,4 +289,48 @@ mod test {
]; ];
assert_eq!(expected, result); assert_eq!(expected, result);
} }
#[test]
fn test_lists() {
let input =
r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":set"),
Cow::from("statusline.center"),
Cow::from(r#"["file-type","file-encoding"]"#),
Cow::from(r#"["list", "in", "qoutes"]"#),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_escaping_unix() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar"));
assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar"));
}
#[test]
#[cfg(windows)]
fn test_escaping_windows() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
}
#[test]
#[cfg(unix)]
fn test_parts() {
assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]);
}
#[test]
#[cfg(windows)]
fn test_parts() {
assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]);
}
} }

@ -1,17 +0,0 @@
use crate::{Rope, Selection};
#[derive(Debug, Clone)]
pub struct State {
pub doc: Rope,
pub selection: Selection,
}
impl State {
#[must_use]
pub fn new(doc: Rope) -> Self {
Self {
doc,
selection: Selection::point(0),
}
}
}

@ -13,7 +13,7 @@ pub const PAIRS: &[(char, char)] = &[
('', ''), ('', ''),
]; ];
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq, Eq)]
pub enum Error { pub enum Error {
PairNotFound, PairNotFound,
CursorOverlap, CursorOverlap,

@ -7,14 +7,19 @@ use crate::{
Rope, RopeSlice, Tendril, Rope, RopeSlice, Tendril,
}; };
use ahash::RandomState;
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
use bitflags::bitflags;
use hashbrown::raw::RawTable;
use slotmap::{DefaultKey as LayerId, HopSlotMap}; use slotmap::{DefaultKey as LayerId, HopSlotMap};
use std::{ use std::{
borrow::Cow, borrow::Cow,
cell::RefCell, cell::RefCell,
collections::{HashMap, HashSet, VecDeque}, collections::{HashMap, VecDeque},
fmt, fmt,
hash::{Hash, Hasher},
mem::{replace, transmute},
path::Path, path::Path,
str::FromStr, str::FromStr,
sync::Arc, sync::Arc,
@ -59,17 +64,23 @@ pub struct Configuration {
pub language: Vec<LanguageConfiguration>, pub language: Vec<LanguageConfiguration>,
} }
impl Default for Configuration {
fn default() -> Self {
crate::config::default_syntax_loader()
}
}
// largely based on tree-sitter/cli/src/loader.rs // largely based on tree-sitter/cli/src/loader.rs
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)] #[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct LanguageConfiguration { pub struct LanguageConfiguration {
#[serde(rename = "name")] #[serde(rename = "name")]
pub language_id: String, // c-sharp, rust pub language_id: String, // c-sharp, rust
pub scope: String, // source.rust pub scope: String, // source.rust
pub file_types: Vec<String>, // filename ends_with? <Gemfile, rb, etc> pub file_types: Vec<FileType>, // filename extension or ends_with? <Gemfile, rb, etc>
#[serde(default)] #[serde(default)]
pub shebangs: Vec<String>, // interpreter(s) associated with language pub shebangs: Vec<String>, // interpreter(s) associated with language
pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml> pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml>
pub comment_token: Option<String>, pub comment_token: Option<String>,
pub max_line_length: Option<usize>, pub max_line_length: Option<usize>,
@ -117,6 +128,78 @@ pub struct LanguageConfiguration {
pub rulers: Option<Vec<u16>>, // if set, override editor's rulers pub rulers: Option<Vec<u16>>, // if set, override editor's rulers
} }
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum FileType {
/// The extension of the file, either the `Path::extension` or the full
/// filename if the file does not have an extension.
Extension(String),
/// The suffix of a file. This is compared to a given file's absolute
/// path, so it can be used to detect files based on their directories.
Suffix(String),
}
impl Serialize for FileType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
match self {
FileType::Extension(extension) => serializer.serialize_str(extension),
FileType::Suffix(suffix) => {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("suffix", &suffix.replace(std::path::MAIN_SEPARATOR, "/"))?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for FileType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct FileTypeVisitor;
impl<'de> serde::de::Visitor<'de> for FileTypeVisitor {
type Value = FileType;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or table")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(FileType::Extension(value.to_string()))
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
match map.next_entry::<String, String>()? {
Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix(
suffix.replace('/', &std::path::MAIN_SEPARATOR.to_string()),
)),
Some((key, _value)) => Err(serde::de::Error::custom(format!(
"unknown key in `file-types` list: {}",
key
))),
None => Err(serde::de::Error::custom(
"expected a `suffix` key in the `file-types` entry",
)),
}
}
}
deserializer.deserialize_any(FileTypeVisitor)
}
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct LanguageServerConfiguration { pub struct LanguageServerConfiguration {
@ -124,6 +207,8 @@ pub struct LanguageServerConfiguration {
#[serde(default)] #[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>, pub args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub environment: HashMap<String, String>,
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub timeout: u64, pub timeout: u64,
pub language_id: Option<String>, pub language_id: Option<String>,
@ -138,7 +223,7 @@ pub struct FormatterConfiguration {
pub args: Vec<String>, pub args: Vec<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct AdvancedCompletion { pub struct AdvancedCompletion {
pub name: Option<String>, pub name: Option<String>,
@ -146,14 +231,14 @@ pub struct AdvancedCompletion {
pub default: Option<String>, pub default: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", untagged)] #[serde(rename_all = "kebab-case", untagged)]
pub enum DebugConfigCompletion { pub enum DebugConfigCompletion {
Named(String), Named(String),
Advanced(AdvancedCompletion), Advanced(AdvancedCompletion),
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum DebugArgumentValue { pub enum DebugArgumentValue {
String(String), String(String),
@ -161,7 +246,7 @@ pub enum DebugArgumentValue {
Boolean(bool), Boolean(bool),
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct DebugTemplate { pub struct DebugTemplate {
pub name: String, pub name: String,
@ -170,7 +255,7 @@ pub struct DebugTemplate {
pub args: HashMap<String, DebugArgumentValue>, pub args: HashMap<String, DebugArgumentValue>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct DebugAdapterConfig { pub struct DebugAdapterConfig {
pub name: String, pub name: String,
@ -186,7 +271,7 @@ pub struct DebugAdapterConfig {
} }
// Different workarounds for adapters' differences // Different workarounds for adapters' differences
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct DebuggerQuirks { pub struct DebuggerQuirks {
#[serde(default)] #[serde(default)]
pub absolute_paths: bool, pub absolute_paths: bool,
@ -200,7 +285,7 @@ pub struct IndentationConfiguration {
} }
/// Configuration for auto pairs /// Configuration for auto pairs
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] #[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)]
pub enum AutoPairConfig { pub enum AutoPairConfig {
/// Enables or disables auto pairing. False means disabled. True means to use the default pairs. /// Enables or disables auto pairing. False means disabled. True means to use the default pairs.
@ -274,6 +359,26 @@ impl<'a> CapturedNode<'a> {
} }
} }
/// The maximum number of in-progress matches a TS cursor can consider at once.
/// This is set to a constant in order to avoid performance problems for medium to large files. Set with `set_match_limit`.
/// Using such a limit means that we lose valid captures, so there is fundamentally a tradeoff here.
///
///
/// Old tree sitter versions used a limit of 32 by default until this limit was removed in version `0.19.5` (must now be set manually).
/// However, this causes performance issues for medium to large files.
/// In helix, this problem caused treesitter motions to take multiple seconds to complete in medium-sized rust files (3k loc).
///
///
/// Neovim also encountered this problem and reintroduced this limit after it was removed upstream
/// (see <https://github.com/neovim/neovim/issues/14897> and <https://github.com/neovim/neovim/pull/14915>).
/// The number used here is fundamentally a tradeoff between breaking some obscure edge cases and performance.
///
///
/// Neovim chose 64 for this value somewhat arbitrarily (<https://github.com/neovim/neovim/pull/18397>).
/// 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 { impl TextObjectQuery {
/// Run the query on the given node and return sub nodes which match given /// Run the query on the given node and return sub nodes which match given
/// capture ("function.inside", "class.around", etc). /// capture ("function.inside", "class.around", etc).
@ -314,6 +419,8 @@ impl TextObjectQuery {
.iter() .iter()
.find_map(|cap| self.query.capture_index_for_name(cap))?; .find_map(|cap| self.query.capture_index_for_name(cap))?;
cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT);
let nodes = cursor let nodes = cursor
.captures(&self.query, node, RopeProvider(slice)) .captures(&self.query, node, RopeProvider(slice))
.filter_map(move |(mat, _)| { .filter_map(move |(mat, _)| {
@ -353,20 +460,24 @@ pub fn read_query(language: &str, filename: &str) -> String {
impl LanguageConfiguration { impl LanguageConfiguration {
fn initialize_highlight(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> { fn initialize_highlight(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> {
let language = self.language_id.to_ascii_lowercase(); let highlights_query = read_query(&self.language_id, "highlights.scm");
let highlights_query = read_query(&language, "highlights.scm");
// always highlight syntax errors // always highlight syntax errors
// highlights_query += "\n(ERROR) @error"; // highlights_query += "\n(ERROR) @error";
let injections_query = read_query(&language, "injections.scm"); let injections_query = read_query(&self.language_id, "injections.scm");
let locals_query = read_query(&language, "locals.scm"); let locals_query = read_query(&self.language_id, "locals.scm");
if highlights_query.is_empty() { if highlights_query.is_empty() {
None None
} else { } else {
let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id)) let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id))
.map_err(|e| log::info!("{}", e)) .map_err(|err| {
log::error!(
"Failed to load tree-sitter parser for language {:?}: {}",
self.language_id,
err
)
})
.ok()?; .ok()?;
let config = HighlightConfiguration::new( let config = HighlightConfiguration::new(
language, language,
@ -418,14 +529,20 @@ impl LanguageConfiguration {
} }
fn load_query(&self, kind: &str) -> Option<Query> { fn load_query(&self, kind: &str) -> Option<Query> {
let lang_name = self.language_id.to_ascii_lowercase(); let query_text = read_query(&self.language_id, kind);
let query_text = read_query(&lang_name, kind);
if query_text.is_empty() { if query_text.is_empty() {
return None; return None;
} }
let lang = self.highlight_config.get()?.as_ref()?.language; let lang = self.highlight_config.get()?.as_ref()?.language;
Query::new(lang, &query_text) Query::new(lang, &query_text)
.map_err(|e| log::error!("Failed to parse {} queries for {}: {}", kind, lang_name, e)) .map_err(|e| {
log::error!(
"Failed to parse {} queries for {}: {}",
kind,
self.language_id,
e
)
})
.ok() .ok()
} }
} }
@ -436,7 +553,8 @@ impl LanguageConfiguration {
pub struct Loader { pub struct Loader {
// highlight_names ? // highlight_names ?
language_configs: Vec<Arc<LanguageConfiguration>>, language_configs: Vec<Arc<LanguageConfiguration>>,
language_config_ids_by_file_type: HashMap<String, usize>, // Vec<usize> language_config_ids_by_extension: HashMap<String, usize>, // Vec<usize>
language_config_ids_by_suffix: HashMap<String, usize>,
language_config_ids_by_shebang: HashMap<String, usize>, language_config_ids_by_shebang: HashMap<String, usize>,
scopes: ArcSwap<Vec<String>>, scopes: ArcSwap<Vec<String>>,
@ -446,7 +564,8 @@ impl Loader {
pub fn new(config: Configuration) -> Self { pub fn new(config: Configuration) -> Self {
let mut loader = Self { let mut loader = Self {
language_configs: Vec::new(), language_configs: Vec::new(),
language_config_ids_by_file_type: HashMap::new(), language_config_ids_by_extension: HashMap::new(),
language_config_ids_by_suffix: HashMap::new(),
language_config_ids_by_shebang: HashMap::new(), language_config_ids_by_shebang: HashMap::new(),
scopes: ArcSwap::from_pointee(Vec::new()), scopes: ArcSwap::from_pointee(Vec::new()),
}; };
@ -457,9 +576,14 @@ impl Loader {
for file_type in &config.file_types { for file_type in &config.file_types {
// entry().or_insert(Vec::new).push(language_id); // entry().or_insert(Vec::new).push(language_id);
loader match file_type {
.language_config_ids_by_file_type FileType::Extension(extension) => loader
.insert(file_type.clone(), language_id); .language_config_ids_by_extension
.insert(extension.clone(), language_id),
FileType::Suffix(suffix) => loader
.language_config_ids_by_suffix
.insert(suffix.clone(), language_id),
};
} }
for shebang in &config.shebangs { for shebang in &config.shebangs {
loader loader
@ -479,11 +603,22 @@ impl Loader {
let configuration_id = path let configuration_id = path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.and_then(|file_name| self.language_config_ids_by_file_type.get(file_name)) .and_then(|file_name| self.language_config_ids_by_extension.get(file_name))
.or_else(|| { .or_else(|| {
path.extension() path.extension()
.and_then(|extension| extension.to_str()) .and_then(|extension| extension.to_str())
.and_then(|extension| self.language_config_ids_by_file_type.get(extension)) .and_then(|extension| self.language_config_ids_by_extension.get(extension))
})
.or_else(|| {
self.language_config_ids_by_suffix
.iter()
.find_map(|(file_type, id)| {
if path.to_str()?.ends_with(file_type) {
Some(id)
} else {
None
}
})
}); });
configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) configuration_id.and_then(|&id| self.language_configs.get(id).cloned())
@ -594,6 +729,7 @@ impl Syntax {
tree: None, tree: None,
config, config,
depth: 0, depth: 0,
flags: LayerUpdateFlags::empty(),
ranges: vec![Range { ranges: vec![Range {
start_byte: 0, start_byte: 0,
end_byte: usize::MAX, end_byte: usize::MAX,
@ -639,29 +775,38 @@ impl Syntax {
// Convert the changeset into tree sitter edits. // Convert the changeset into tree sitter edits.
let edits = generate_edits(old_source, changeset); 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 // Use the edits to update all layers markers
if !edits.is_empty() { fn point_add(a: Point, b: Point) -> Point {
fn point_add(a: Point, b: Point) -> Point { if b.row > 0 {
if b.row > 0 { Point::new(a.row.saturating_add(b.row), b.column)
Point::new(a.row.saturating_add(b.row), b.column) } else {
} else { Point::new(0, a.column.saturating_add(b.column))
Point::new(0, a.column.saturating_add(b.column))
}
} }
fn point_sub(a: Point, b: Point) -> Point { }
if a.row > b.row { fn point_sub(a: Point, b: Point) -> Point {
Point::new(a.row.saturating_sub(b.row), a.column) if a.row > b.row {
} else { Point::new(a.row.saturating_sub(b.row), a.column)
Point::new(0, a.column.saturating_sub(b.column)) } else {
} Point::new(0, a.column.saturating_sub(b.column))
} }
}
for layer in &mut self.layers.values_mut() { for (layer_id, layer) in self.layers.iter_mut() {
// The root layer always covers the whole range (0..usize::MAX) // The root layer always covers the whole range (0..usize::MAX)
if layer.depth == 0 { if layer.depth == 0 {
continue; layer.flags = LayerUpdateFlags::MODIFIED;
} continue;
}
if !edits.is_empty() {
for range in &mut layer.ranges { for range in &mut layer.ranges {
// Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720 // Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720
for edit in edits.iter().rev() { for edit in edits.iter().rev() {
@ -689,6 +834,8 @@ impl Syntax {
edit.new_end_position, edit.new_end_position,
point_sub(range.end_point, edit.old_end_position), point_sub(range.end_point, edit.old_end_position),
); );
layer.flags |= LayerUpdateFlags::MOVED;
} }
// if the edit starts in the space before and extends into the range // if the edit starts in the space before and extends into the range
else if edit.start_byte < range.start_byte { else if edit.start_byte < range.start_byte {
@ -703,11 +850,13 @@ impl Syntax {
edit.new_end_position, edit.new_end_position,
point_sub(range.end_point, edit.old_end_position), point_sub(range.end_point, edit.old_end_position),
); );
layer.flags = LayerUpdateFlags::MODIFIED;
} }
// If the edit is an insertion at the start of the tree, shift // If the edit is an insertion at the start of the tree, shift
else if edit.start_byte == range.start_byte && is_pure_insertion { else if edit.start_byte == range.start_byte && is_pure_insertion {
range.start_byte = edit.new_end_byte; range.start_byte = edit.new_end_byte;
range.start_point = edit.new_end_position; range.start_point = edit.new_end_position;
layer.flags |= LayerUpdateFlags::MOVED;
} else { } else {
range.end_byte = range range.end_byte = range
.end_byte .end_byte
@ -717,10 +866,17 @@ impl Syntax {
edit.new_end_position, edit.new_end_position,
point_sub(range.end_point, edit.old_end_position), point_sub(range.end_point, edit.old_end_position),
); );
layer.flags = LayerUpdateFlags::MODIFIED;
} }
} }
} }
} }
let hash = layers_hasher.hash_one(layer);
// Safety: insert_no_grow is unsafe because it assumes that the table
// has enough capacity to hold additional elements.
// This is always the case as we reserved enough capacity above.
unsafe { layers_table.insert_no_grow(hash, layer_id) };
} }
PARSER.with(|ts_parser| { PARSER.with(|ts_parser| {
@ -728,30 +884,37 @@ impl Syntax {
let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new);
// TODO: might need to set cursor range // TODO: might need to set cursor range
cursor.set_byte_range(0..usize::MAX); cursor.set_byte_range(0..usize::MAX);
cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT);
let source_slice = source.slice(..); let source_slice = source.slice(..);
let mut touched = HashSet::new();
// TODO: we should be able to avoid editing & parsing layers with ranges earlier in the document before the edit
while let Some(layer_id) = queue.pop_front() { while let Some(layer_id) = queue.pop_front() {
// Mark the layer as touched
touched.insert(layer_id);
let layer = &mut self.layers[layer_id]; let layer = &mut self.layers[layer_id];
// Mark the layer as touched
layer.flags |= LayerUpdateFlags::TOUCHED;
// If a tree already exists, notify it of changes. // If a tree already exists, notify it of changes.
if let Some(tree) = &mut layer.tree { if let Some(tree) = &mut layer.tree {
for edit in edits.iter().rev() { if layer
// Apply the edits in reverse. .flags
// If we applied them in order then edit 1 would disrupt the positioning of edit 2. .intersects(LayerUpdateFlags::MODIFIED | LayerUpdateFlags::MOVED)
tree.edit(edit); {
for edit in edits.iter().rev() {
// Apply the edits in reverse.
// If we applied them in order then edit 1 would disrupt the positioning of edit 2.
tree.edit(edit);
}
} }
}
// Re-parse the tree. if layer.flags.contains(LayerUpdateFlags::MODIFIED) {
layer.parse(&mut ts_parser.parser, source)?; // Re-parse the tree.
layer.parse(&mut ts_parser.parser, source)?;
}
} else {
// always parse if this layer has never been parsed before
layer.parse(&mut ts_parser.parser, source)?;
}
// Switch to an immutable borrow. // Switch to an immutable borrow.
let layer = &self.layers[layer_id]; let layer = &self.layers[layer_id];
@ -838,25 +1001,23 @@ impl Syntax {
let depth = layer.depth + 1; let depth = layer.depth + 1;
// TODO: can't inline this since matches borrows self.layers // TODO: can't inline this since matches borrows self.layers
for (config, ranges) in injections { for (config, ranges) in injections {
// Find an existing layer let new_layer = LanguageLayer {
let layer = self tree: None,
.layers config,
.iter_mut() depth,
.find(|(_, layer)| { ranges,
layer.depth == depth && // TODO: track parent id instead flags: LayerUpdateFlags::empty(),
layer.config.language == config.language && layer.ranges == ranges };
// 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. // ...or insert a new one.
let layer_id = layer.unwrap_or_else(|| { let layer_id = layer.unwrap_or_else(|| self.layers.insert(new_layer));
self.layers.insert(LanguageLayer {
tree: None,
config,
depth,
ranges,
})
});
queue.push_back(layer_id); queue.push_back(layer_id);
} }
@ -868,8 +1029,11 @@ impl Syntax {
// Return the cursor back in the pool. // Return the cursor back in the pool.
ts_parser.cursors.push(cursor); ts_parser.cursors.push(cursor);
// Remove all untouched layers // Reset all `LayerUpdateFlags` and remove all untouched layers
self.layers.retain(|id, _| touched.contains(&id)); self.layers.retain(|_, layer| {
replace(&mut layer.flags, LayerUpdateFlags::empty())
.contains(LayerUpdateFlags::TOUCHED)
});
Ok(()) Ok(())
}) })
@ -906,6 +1070,7 @@ impl Syntax {
// if reusing cursors & no range this resets to whole range // if reusing cursors & no range this resets to whole range
cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX));
cursor_ref.set_match_limit(TREE_SITTER_MATCH_LIMIT);
let mut captures = cursor_ref let mut captures = cursor_ref
.captures( .captures(
@ -968,6 +1133,16 @@ impl Syntax {
// TODO: Folding // TODO: Folding
} }
bitflags! {
/// Flags that track the status of a layer
/// in the `Sytaxn::update` function
struct LayerUpdateFlags : u32{
const MODIFIED = 0b001;
const MOVED = 0b010;
const TOUCHED = 0b100;
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct LanguageLayer { pub struct LanguageLayer {
// mode // mode
@ -975,7 +1150,36 @@ pub struct LanguageLayer {
pub config: Arc<HighlightConfiguration>, pub config: Arc<HighlightConfiguration>,
pub(crate) tree: Option<Tree>, pub(crate) tree: Option<Tree>,
pub ranges: Vec<Range>, pub ranges: Vec<Range>,
pub depth: usize, pub depth: u32,
flags: LayerUpdateFlags,
}
/// This PartialEq implementation only checks if that
/// two layers are theoretically identical (meaning they highlight the same text range with the same language).
/// It does not check whether the layers have the same internal treesitter
/// state.
impl PartialEq for LanguageLayer {
fn eq(&self, other: &Self) -> bool {
self.depth == other.depth
&& self.config.language == other.config.language
&& self.ranges == other.ranges
}
}
/// Hash implementation belongs to PartialEq implementation above.
/// See its documentation for details.
impl Hash for LanguageLayer {
fn hash<H: Hasher>(&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 { impl LanguageLayer {
@ -1123,7 +1327,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::{iter, mem, ops, str, usize}; use std::{iter, mem, ops, str, usize};
use tree_sitter::{ use tree_sitter::{
Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, QueryError, 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; const CANCELLATION_CHECK_INTERVAL: usize = 100;
@ -1191,7 +1395,7 @@ struct HighlightIter<'a> {
layers: Vec<HighlightIterLayer<'a>>, layers: Vec<HighlightIterLayer<'a>>,
iter_count: usize, iter_count: usize,
next_event: Option<HighlightEvent>, next_event: Option<HighlightEvent>,
last_highlight_range: Option<(usize, usize, usize)>, last_highlight_range: Option<(usize, usize, u32)>,
} }
// Adapter to convert rope chunks to bytes // Adapter to convert rope chunks to bytes
@ -1224,7 +1428,7 @@ struct HighlightIterLayer<'a> {
config: &'a HighlightConfiguration, config: &'a HighlightConfiguration,
highlight_end_stack: Vec<usize>, highlight_end_stack: Vec<usize>,
scope_stack: Vec<LocalScope<'a>>, scope_stack: Vec<LocalScope<'a>>,
depth: usize, depth: u32,
ranges: &'a [Range], ranges: &'a [Range],
} }
@ -1993,6 +2197,68 @@ impl<I: Iterator<Item = HighlightEvent>> Iterator for Merge<I> {
} }
} }
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<W: fmt::Write>(fmt: &mut W, node: Node) -> fmt::Result {
if node.child_count() == 0 {
if node_is_visible(&node) {
write!(fmt, "({})", node.kind())
} else {
write!(fmt, "\"{}\"", node.kind())
}
} else {
pretty_print_tree_impl(fmt, &mut node.walk(), 0)
}
}
fn pretty_print_tree_impl<W: fmt::Write>(
fmt: &mut W,
cursor: &mut TreeCursor,
depth: usize,
) -> fmt::Result {
let node = cursor.node();
let visible = node_is_visible(&node);
if visible {
let indentation_columns = depth * 2;
write!(fmt, "{:indentation_columns$}", "")?;
if let Some(field_name) = cursor.field_name() {
write!(fmt, "{}: ", field_name)?;
}
write!(fmt, "({}", node.kind())?;
}
// Handle children.
if cursor.goto_first_child() {
loop {
if node_is_visible(&cursor.node()) {
fmt.write_char('\n')?;
}
pretty_print_tree_impl(fmt, cursor, depth + 1)?;
if !cursor.goto_next_sibling() {
break;
}
}
let moved = cursor.goto_parent();
// The parent of the first child must exist, and must be `node`.
debug_assert!(moved);
debug_assert!(cursor.node() == node);
}
if visible {
fmt.write_char(')')?;
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -2013,7 +2279,7 @@ mod test {
); );
let loader = Loader::new(Configuration { language: vec![] }); let loader = Loader::new(Configuration { language: vec![] });
let language = get_language("Rust").unwrap(); let language = get_language("rust").unwrap();
let query = Query::new(language, query_str).unwrap(); let query = Query::new(language, query_str).unwrap();
let textobject = TextObjectQuery { query }; let textobject = TextObjectQuery { query };
@ -2073,7 +2339,7 @@ mod test {
let loader = Loader::new(Configuration { language: vec![] }); let loader = Loader::new(Configuration { language: vec![] });
let language = get_language("Rust").unwrap(); let language = get_language("rust").unwrap();
let config = HighlightConfiguration::new( let config = HighlightConfiguration::new(
language, language,
&std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm") &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm")
@ -2164,6 +2430,93 @@ mod test {
); );
} }
#[track_caller]
fn assert_pretty_print(
language_name: &str,
source: &str,
expected: &str,
start: usize,
end: usize,
) {
let source = Rope::from_str(source);
let loader = Loader::new(Configuration { language: vec![] });
let language = get_language(language_name).unwrap();
let config = HighlightConfiguration::new(language, "", "", "").unwrap();
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader));
let root = syntax
.tree()
.root_node()
.descendant_for_byte_range(start, end)
.unwrap();
let mut output = String::new();
pretty_print_tree(&mut output, root).unwrap();
assert_eq!(expected, output);
}
#[test]
fn test_pretty_print() {
let source = r#"/// Hello"#;
assert_pretty_print("rust", source, "(line_comment)", 0, source.len());
// A large tree should be indented with fields:
let source = r#"fn main() {
println!("Hello, World!");
}"#;
assert_pretty_print(
"rust",
source,
concat!(
"(function_item\n",
" name: (identifier)\n",
" parameters: (parameters)\n",
" body: (block\n",
" (expression_statement\n",
" (macro_invocation\n",
" macro: (identifier)\n",
" (token_tree\n",
" (string_literal))))))",
),
0,
source.len(),
);
// Selecting a token should print just that token:
let source = r#"fn main() {}"#;
assert_pretty_print("rust", source, r#""fn""#, 0, 1);
// Error nodes are printed as errors:
let source = r#"}{"#;
assert_pretty_print("rust", source, "(ERROR)", 0, source.len());
// Fields broken under unnamed nodes are determined correctly.
// In the following source, `object` belongs to the `singleton_method`
// rule but `name` and `body` belong to an unnamed helper `_method_rest`.
// This can cause a bug with a pretty-printing implementation that
// uses `Node::field_name_for_child` to determine field names but is
// fixed when using `TreeCursor::field_name`.
let source = "def self.method_name
true
end";
assert_pretty_print(
"ruby",
source,
concat!(
"(singleton_method\n",
" object: (self)\n",
" name: (identifier)\n",
" body: (body_statement\n",
" (true)))"
),
0,
source.len(),
);
}
#[test] #[test]
fn test_load_runtime_file() { fn test_load_runtime_file() {
// Test to make sure we can load some data from the runtime directory. // Test to make sure we can load some data from the runtime directory.

@ -34,7 +34,7 @@ pub fn print(s: &str) -> (String, Selection) {
let mut left = String::with_capacity(s.len()); let mut left = String::with_capacity(s.len());
'outer: while let Some(c) = iter.next() { 'outer: while let Some(c) = iter.next() {
let start = left.len(); let start = left.chars().count();
if c != '#' { if c != '#' {
left.push(c); left.push(c);
@ -63,6 +63,7 @@ pub fn print(s: &str) -> (String, Selection) {
left.push(c); left.push(c);
continue; continue;
} }
if !head_at_beg { if !head_at_beg {
let prev = left.pop().unwrap(); let prev = left.pop().unwrap();
if prev != '|' { if prev != '|' {
@ -71,15 +72,18 @@ pub fn print(s: &str) -> (String, Selection) {
continue; continue;
} }
} }
iter.next(); // skip "#" iter.next(); // skip "#"
if is_primary { if is_primary {
primary_idx = Some(ranges.len()); primary_idx = Some(ranges.len());
} }
let (anchor, head) = match head_at_beg { let (anchor, head) = match head_at_beg {
true => (left.len(), start), true => (left.chars().count(), start),
false => (start, left.len()), false => (start, left.chars().count()),
}; };
ranges.push(Range::new(anchor, head)); ranges.push(Range::new(anchor, head));
continue 'outer; continue 'outer;
} }
@ -95,6 +99,7 @@ pub fn print(s: &str) -> (String, Selection) {
Some(i) => i, Some(i) => i,
None => panic!("missing primary `#[|]#` {:?}", s), None => panic!("missing primary `#[|]#` {:?}", s),
}; };
let selection = Selection::new(ranges, primary); let selection = Selection::new(ranges, primary);
(left, selection) (left, selection)
} }
@ -141,3 +146,120 @@ pub fn plain(s: &str, selection: Selection) -> String {
} }
out out
} }
#[cfg(test)]
#[allow(clippy::module_inception)]
mod test {
use super::*;
#[test]
fn print_single() {
assert_eq!(
(String::from("hello"), Selection::single(1, 0)),
print("#[|h]#ello")
);
assert_eq!(
(String::from("hello"), Selection::single(0, 1)),
print("#[h|]#ello")
);
assert_eq!(
(String::from("hello"), Selection::single(4, 0)),
print("#[|hell]#o")
);
assert_eq!(
(String::from("hello"), Selection::single(0, 4)),
print("#[hell|]#o")
);
assert_eq!(
(String::from("hello"), Selection::single(5, 0)),
print("#[|hello]#")
);
assert_eq!(
(String::from("hello"), Selection::single(0, 5)),
print("#[hello|]#")
);
}
#[test]
fn print_multi() {
assert_eq!(
(
String::from("hello"),
Selection::new(
SmallVec::from_slice(&[Range::new(1, 0), Range::new(5, 4)]),
0
)
),
print("#[|h]#ell#(|o)#")
);
assert_eq!(
(
String::from("hello"),
Selection::new(
SmallVec::from_slice(&[Range::new(0, 1), Range::new(4, 5)]),
0
)
),
print("#[h|]#ell#(o|)#")
);
assert_eq!(
(
String::from("hello"),
Selection::new(
SmallVec::from_slice(&[Range::new(2, 0), Range::new(5, 3)]),
0
)
),
print("#[|he]#l#(|lo)#")
);
assert_eq!(
(
String::from("hello\r\nhello\r\nhello\r\n"),
Selection::new(
SmallVec::from_slice(&[
Range::new(7, 5),
Range::new(21, 19),
Range::new(14, 12)
]),
0
)
),
print("hello#[|\r\n]#hello#(|\r\n)#hello#(|\r\n)#")
);
}
#[test]
fn print_multi_byte_code_point() {
assert_eq!(
(String::from("„“"), Selection::single(1, 0)),
print("#[|„]#“")
);
assert_eq!(
(String::from("„“"), Selection::single(2, 1)),
print("„#[|“]#")
);
assert_eq!(
(String::from("„“"), Selection::single(0, 1)),
print("#[„|]#“")
);
assert_eq!(
(String::from("„“"), Selection::single(1, 2)),
print("„#[“|]#")
);
assert_eq!(
(String::from("they said „hello“"), Selection::single(11, 10)),
print("they said #[|„]#hello“")
);
}
#[test]
fn print_multi_code_point_grapheme() {
assert_eq!(
(
String::from("hello 👨‍👩‍👧‍👦 goodbye"),
Selection::single(13, 6)
),
print("hello #[|👨‍👩‍👧‍👦]# goodbye")
);
}
}

@ -56,7 +56,7 @@ impl ChangeSet {
} }
// Changeset builder operations: delete/insert/retain // Changeset builder operations: delete/insert/retain
fn delete(&mut self, n: usize) { pub(crate) fn delete(&mut self, n: usize) {
use Operation::*; use Operation::*;
if n == 0 { if n == 0 {
return; return;
@ -71,7 +71,7 @@ impl ChangeSet {
} }
} }
fn insert(&mut self, fragment: Tendril) { pub(crate) fn insert(&mut self, fragment: Tendril) {
use Operation::*; use Operation::*;
if fragment.is_empty() { if fragment.is_empty() {
@ -93,7 +93,7 @@ impl ChangeSet {
self.changes.push(new_last); self.changes.push(new_last);
} }
fn retain(&mut self, n: usize) { pub(crate) fn retain(&mut self, n: usize) {
use Operation::*; use Operation::*;
if n == 0 { if n == 0 {
return; return;
@ -577,7 +577,7 @@ impl<'a> Iterator for ChangeIterator<'a> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::State; use crate::history::State;
#[test] #[test]
fn composition() { fn composition() {
@ -704,7 +704,10 @@ mod test {
#[test] #[test]
fn optimized_composition() { fn optimized_composition() {
let mut state = State::new("".into()); let mut state = State {
doc: "".into(),
selection: Selection::point(0),
};
let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from("h")); let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from("h"));
t1.apply(&mut state.doc); t1.apply(&mut state.doc);
state.selection = state.selection.clone().map(t1.changes()); state.selection = state.selection.clone().map(t1.changes());

@ -10,4 +10,4 @@ indent = { tab-width = 4, unit = " " }
[[grammar]] [[grammar]]
name = "rust" name = "rust"
source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a360da0a29a19c281d08295a35ecd0544d2da211" } source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "0431a2c60828731f27491ee9fdefe25e250ce9c9" }

@ -50,6 +50,7 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) {
indent_query, indent_query,
&syntax, &syntax,
&IndentStyle::Spaces(4), &IndentStyle::Spaces(4),
4,
text, text,
i, i,
text.line_to_char(i) + pos, text.line_to_char(i) + pos,

@ -22,7 +22,7 @@ pub struct Request {
pub arguments: Option<Value>, pub arguments: Option<Value>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
pub struct Response { pub struct Response {
// seq is omitted as unused and is not sent by some implementations // seq is omitted as unused and is not sent by some implementations
pub request_seq: u64, pub request_seq: u64,

@ -22,7 +22,7 @@ pub trait Request {
const COMMAND: &'static str; const COMMAND: &'static str;
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ColumnDescriptor { pub struct ColumnDescriptor {
pub attribute_name: String, pub attribute_name: String,
@ -35,7 +35,7 @@ pub struct ColumnDescriptor {
pub width: Option<usize>, pub width: Option<usize>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ExceptionBreakpointsFilter { pub struct ExceptionBreakpointsFilter {
pub filter: String, pub filter: String,
@ -50,7 +50,7 @@ pub struct ExceptionBreakpointsFilter {
pub condition_description: Option<String>, pub condition_description: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DebuggerCapabilities { pub struct DebuggerCapabilities {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -131,14 +131,14 @@ pub struct DebuggerCapabilities {
pub supported_checksum_algorithms: Option<Vec<String>>, pub supported_checksum_algorithms: Option<Vec<String>>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Checksum { pub struct Checksum {
pub algorithm: String, pub algorithm: String,
pub checksum: String, pub checksum: String,
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Source { pub struct Source {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -159,7 +159,7 @@ pub struct Source {
pub checksums: Option<Vec<Checksum>>, pub checksums: Option<Vec<Checksum>>,
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SourceBreakpoint { pub struct SourceBreakpoint {
pub line: usize, pub line: usize,
@ -173,7 +173,7 @@ pub struct SourceBreakpoint {
pub log_message: Option<String>, pub log_message: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Breakpoint { pub struct Breakpoint {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -197,7 +197,7 @@ pub struct Breakpoint {
pub offset: Option<usize>, pub offset: Option<usize>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StackFrameFormat { pub struct StackFrameFormat {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -216,7 +216,7 @@ pub struct StackFrameFormat {
pub include_all: Option<bool>, pub include_all: Option<bool>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StackFrame { pub struct StackFrame {
pub id: usize, pub id: usize,
@ -239,14 +239,14 @@ pub struct StackFrame {
pub presentation_hint: Option<String>, pub presentation_hint: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Thread { pub struct Thread {
pub id: ThreadId, pub id: ThreadId,
pub name: String, pub name: String,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Scope { pub struct Scope {
pub name: String, pub name: String,
@ -270,14 +270,14 @@ pub struct Scope {
pub end_column: Option<usize>, pub end_column: Option<usize>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ValueFormat { pub struct ValueFormat {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub hex: Option<bool>, pub hex: Option<bool>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VariablePresentationHint { pub struct VariablePresentationHint {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -288,7 +288,7 @@ pub struct VariablePresentationHint {
pub visibility: Option<String>, pub visibility: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Variable { pub struct Variable {
pub name: String, pub name: String,
@ -308,7 +308,7 @@ pub struct Variable {
pub memory_reference: Option<String>, pub memory_reference: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Module { pub struct Module {
pub id: String, // TODO: || number pub id: String, // TODO: || number
@ -333,7 +333,7 @@ pub struct Module {
pub mod requests { pub mod requests {
use super::*; use super::*;
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct InitializeArguments { pub struct InitializeArguments {
#[serde(rename = "clientID", skip_serializing_if = "Option::is_none")] #[serde(rename = "clientID", skip_serializing_if = "Option::is_none")]
@ -409,7 +409,7 @@ pub mod requests {
const COMMAND: &'static str = "configurationDone"; const COMMAND: &'static str = "configurationDone";
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetBreakpointsArguments { pub struct SetBreakpointsArguments {
pub source: Source, pub source: Source,
@ -420,7 +420,7 @@ pub mod requests {
pub source_modified: Option<bool>, pub source_modified: Option<bool>,
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetBreakpointsResponse { pub struct SetBreakpointsResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -436,13 +436,13 @@ pub mod requests {
const COMMAND: &'static str = "setBreakpoints"; const COMMAND: &'static str = "setBreakpoints";
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ContinueArguments { pub struct ContinueArguments {
pub thread_id: ThreadId, pub thread_id: ThreadId,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ContinueResponse { pub struct ContinueResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -458,7 +458,7 @@ pub mod requests {
const COMMAND: &'static str = "continue"; const COMMAND: &'static str = "continue";
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StackTraceArguments { pub struct StackTraceArguments {
pub thread_id: ThreadId, pub thread_id: ThreadId,
@ -470,7 +470,7 @@ pub mod requests {
pub format: Option<StackFrameFormat>, pub format: Option<StackFrameFormat>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StackTraceResponse { pub struct StackTraceResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -487,7 +487,7 @@ pub mod requests {
const COMMAND: &'static str = "stackTrace"; const COMMAND: &'static str = "stackTrace";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ThreadsResponse { pub struct ThreadsResponse {
pub threads: Vec<Thread>, pub threads: Vec<Thread>,
@ -502,13 +502,13 @@ pub mod requests {
const COMMAND: &'static str = "threads"; const COMMAND: &'static str = "threads";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ScopesArguments { pub struct ScopesArguments {
pub frame_id: usize, pub frame_id: usize,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ScopesResponse { pub struct ScopesResponse {
pub scopes: Vec<Scope>, pub scopes: Vec<Scope>,
@ -523,7 +523,7 @@ pub mod requests {
const COMMAND: &'static str = "scopes"; const COMMAND: &'static str = "scopes";
} }
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VariablesArguments { pub struct VariablesArguments {
pub variables_reference: usize, pub variables_reference: usize,
@ -537,7 +537,7 @@ pub mod requests {
pub format: Option<ValueFormat>, pub format: Option<ValueFormat>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VariablesResponse { pub struct VariablesResponse {
pub variables: Vec<Variable>, pub variables: Vec<Variable>,
@ -552,7 +552,7 @@ pub mod requests {
const COMMAND: &'static str = "variables"; const COMMAND: &'static str = "variables";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StepInArguments { pub struct StepInArguments {
pub thread_id: ThreadId, pub thread_id: ThreadId,
@ -571,7 +571,7 @@ pub mod requests {
const COMMAND: &'static str = "stepIn"; const COMMAND: &'static str = "stepIn";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StepOutArguments { pub struct StepOutArguments {
pub thread_id: ThreadId, pub thread_id: ThreadId,
@ -588,7 +588,7 @@ pub mod requests {
const COMMAND: &'static str = "stepOut"; const COMMAND: &'static str = "stepOut";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct NextArguments { pub struct NextArguments {
pub thread_id: ThreadId, pub thread_id: ThreadId,
@ -605,7 +605,7 @@ pub mod requests {
const COMMAND: &'static str = "next"; const COMMAND: &'static str = "next";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PauseArguments { pub struct PauseArguments {
pub thread_id: ThreadId, pub thread_id: ThreadId,
@ -620,7 +620,7 @@ pub mod requests {
const COMMAND: &'static str = "pause"; const COMMAND: &'static str = "pause";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EvaluateArguments { pub struct EvaluateArguments {
pub expression: String, pub expression: String,
@ -632,7 +632,7 @@ pub mod requests {
pub format: Option<ValueFormat>, pub format: Option<ValueFormat>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EvaluateResponse { pub struct EvaluateResponse {
pub result: String, pub result: String,
@ -658,7 +658,7 @@ pub mod requests {
const COMMAND: &'static str = "evaluate"; const COMMAND: &'static str = "evaluate";
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetExceptionBreakpointsArguments { pub struct SetExceptionBreakpointsArguments {
pub filters: Vec<String>, pub filters: Vec<String>,
@ -666,7 +666,7 @@ pub mod requests {
// pub exceptionOptions: Option<Vec<ExceptionOptions>>, // needs capability // pub exceptionOptions: Option<Vec<ExceptionOptions>>, // needs capability
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetExceptionBreakpointsResponse { pub struct SetExceptionBreakpointsResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -684,7 +684,7 @@ pub mod requests {
// Reverse Requests // Reverse Requests
#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RunInTerminalResponse { pub struct RunInTerminalResponse {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -693,7 +693,7 @@ pub mod requests {
pub shell_process_id: Option<u32>, pub shell_process_id: Option<u32>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RunInTerminalArguments { pub struct RunInTerminalArguments {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -726,7 +726,7 @@ pub mod events {
#[serde(tag = "event", content = "body")] #[serde(tag = "event", content = "body")]
// seq is omitted as unused and is not sent by some implementations // seq is omitted as unused and is not sent by some implementations
pub enum Event { pub enum Event {
Initialized, Initialized(Option<DebuggerCapabilities>),
Stopped(Stopped), Stopped(Stopped),
Continued(Continued), Continued(Continued),
Exited(Exited), Exited(Exited),
@ -745,7 +745,7 @@ pub mod events {
Memory(Memory), Memory(Memory),
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Stopped { pub struct Stopped {
pub reason: String, pub reason: String,
@ -763,7 +763,7 @@ pub mod events {
pub hit_breakpoint_ids: Option<Vec<usize>>, pub hit_breakpoint_ids: Option<Vec<usize>>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Continued { pub struct Continued {
pub thread_id: ThreadId, pub thread_id: ThreadId,
@ -771,27 +771,27 @@ pub mod events {
pub all_threads_continued: Option<bool>, pub all_threads_continued: Option<bool>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Exited { pub struct Exited {
pub exit_code: usize, pub exit_code: usize,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Terminated { pub struct Terminated {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub restart: Option<Value>, pub restart: Option<Value>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Thread { pub struct Thread {
pub reason: String, pub reason: String,
pub thread_id: ThreadId, pub thread_id: ThreadId,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Output { pub struct Output {
pub output: String, pub output: String,
@ -811,28 +811,28 @@ pub mod events {
pub data: Option<Value>, pub data: Option<Value>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Breakpoint { pub struct Breakpoint {
pub reason: String, pub reason: String,
pub breakpoint: super::Breakpoint, pub breakpoint: super::Breakpoint,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Module { pub struct Module {
pub reason: String, pub reason: String,
pub module: super::Module, pub module: super::Module,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LoadedSource { pub struct LoadedSource {
pub reason: String, pub reason: String,
pub source: super::Source, pub source: super::Source,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Process { pub struct Process {
pub name: String, pub name: String,
@ -846,13 +846,13 @@ pub mod events {
pub pointer_size: Option<usize>, pub pointer_size: Option<usize>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Capabilities { pub struct Capabilities {
pub capabilities: super::DebuggerCapabilities, pub capabilities: super::DebuggerCapabilities,
} }
// #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] // #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
// #[serde(rename_all = "camelCase")] // #[serde(rename_all = "camelCase")]
// pub struct Invalidated { // pub struct Invalidated {
// pub areas: Vec<InvalidatedArea>, // pub areas: Vec<InvalidatedArea>,
@ -860,7 +860,7 @@ pub mod events {
// pub stack_frame_id: Option<usize>, // pub stack_frame_id: Option<usize>,
// } // }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Memory { pub struct Memory {
pub memory_reference: String, pub memory_reference: String,

@ -19,7 +19,7 @@ serde = { version = "1.0", features = ["derive"] }
toml = "0.5" toml = "0.5"
etcetera = "0.4" etcetera = "0.4"
tree-sitter = "0.20" tree-sitter = "0.20"
once_cell = "1.14" once_cell = "1.16"
log = "0.4" log = "0.4"
# TODO: these two should be on !wasm32 only # TODO: these two should be on !wasm32 only

@ -1,6 +1,26 @@
use std::borrow::Cow;
use std::process::Command;
const VERSION: &str = include_str!("../VERSION");
fn main() { 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!( println!(
"cargo:rustc-env=BUILD_TARGET={}", "cargo:rustc-env=BUILD_TARGET={}",
std::env::var("TARGET").unwrap() std::env::var("TARGET").unwrap()
); );
println!("cargo:rerun-if-changed=../VERSION");
println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version);
} }

@ -67,8 +67,7 @@ pub fn get_language(name: &str) -> Result<Language> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn get_language(name: &str) -> Result<Language> { pub fn get_language(name: &str) -> Result<Language> {
use libloading::{Library, Symbol}; use libloading::{Library, Symbol};
let name = name.to_ascii_lowercase(); let mut library_path = crate::runtime_dir().join("grammars").join(name);
let mut library_path = crate::runtime_dir().join("grammars").join(&name);
library_path.set_extension(DYLIB_EXTENSION); library_path.set_extension(DYLIB_EXTENSION);
let library = unsafe { Library::new(&library_path) } let library = unsafe { Library::new(&library_path) }
@ -264,7 +263,7 @@ fn fetch_grammar(grammar: GrammarConfiguration) -> Result<FetchStatus> {
))?; ))?;
// create the grammar dir contains a git directory // 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"])?; git(&grammar_dir, ["init"])?;
} }
@ -430,7 +429,7 @@ fn build_tree_sitter_library(
if cfg!(all(windows, target_env = "msvc")) { if cfg!(all(windows, target_env = "msvc")) {
command command
.args(&["/nologo", "/LD", "/I"]) .args(["/nologo", "/LD", "/I"])
.arg(header_path) .arg(header_path)
.arg("/Od") .arg("/Od")
.arg("/utf-8"); .arg("/utf-8");

@ -4,6 +4,8 @@ pub mod grammar;
use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; use etcetera::base_strategy::{choose_base_strategy, BaseStrategy};
use std::path::PathBuf; use std::path::PathBuf;
pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH");
pub static RUNTIME_DIR: once_cell::sync::Lazy<PathBuf> = once_cell::sync::Lazy::new(runtime_dir); pub static RUNTIME_DIR: once_cell::sync::Lazy<PathBuf> = once_cell::sync::Lazy::new(runtime_dir);
static CONFIG_FILE: once_cell::sync::OnceCell<PathBuf> = once_cell::sync::OnceCell::new(); static CONFIG_FILE: once_cell::sync::OnceCell<PathBuf> = once_cell::sync::OnceCell::new();
@ -59,7 +61,7 @@ pub fn config_dir() -> PathBuf {
} }
pub fn local_config_dirs() -> Vec<PathBuf> { pub fn local_config_dirs() -> Vec<PathBuf> {
let directories = find_root_impl(None, &[".helix".to_string()]) let directories = find_local_config_dirs()
.into_iter() .into_iter()
.map(|path| path.join(".helix")) .map(|path| path.join(".helix"))
.collect(); .collect();
@ -90,32 +92,16 @@ pub fn log_file() -> PathBuf {
cache_dir().join("helix.log") cache_dir().join("helix.log")
} }
pub fn find_root_impl(root: Option<&str>, root_markers: &[String]) -> Vec<PathBuf> { pub fn find_local_config_dirs() -> Vec<PathBuf> {
let current_dir = std::env::current_dir().expect("unable to determine current directory"); let current_dir = std::env::current_dir().expect("unable to determine current directory");
let mut directories = Vec::new(); let mut directories = Vec::new();
let root = match root { for ancestor in current_dir.ancestors() {
Some(root) => { if ancestor.join(".git").exists() {
let root = std::path::Path::new(root);
if root.is_absolute() {
root.to_path_buf()
} else {
current_dir.join(root)
}
}
None => current_dir,
};
for ancestor in root.ancestors() {
// don't go higher than repo
if ancestor.join(".git").is_dir() {
// Use workspace if detected from marker
directories.push(ancestor.to_path_buf()); directories.push(ancestor.to_path_buf());
// Don't go higher than repo if we're in one
break; break;
} else if root_markers } else if ancestor.join(".helix").is_dir() {
.iter()
.any(|marker| ancestor.join(marker).exists())
{
directories.push(ancestor.to_path_buf()); directories.push(ancestor.to_path_buf());
} }
} }

@ -13,6 +13,7 @@ homepage = "https://helix-editor.com"
[dependencies] [dependencies]
helix-core = { version = "0.6", path = "../helix-core" } helix-core = { version = "0.6", path = "../helix-core" }
helix-loader = { version = "0.6", path = "../helix-loader" }
anyhow = "1.0" anyhow = "1.0"
futures-executor = "0.3" futures-executor = "0.3"
@ -22,6 +23,6 @@ lsp-types = { version = "0.93", features = ["proposed"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "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.23", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.9" tokio-stream = "0.1.11"
which = "4.2" which = "4.2"

@ -4,8 +4,8 @@ use crate::{
Call, Error, OffsetEncoding, Result, Call, Error, OffsetEncoding, Result,
}; };
use anyhow::anyhow;
use helix_core::{find_root, ChangeSet, Rope}; use helix_core::{find_root, ChangeSet, Rope};
use helix_loader::{self, VERSION_AND_GIT_HASH};
use lsp_types as lsp; use lsp_types as lsp;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
@ -34,7 +34,7 @@ pub struct Client {
pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>, pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>,
offset_encoding: OffsetEncoding, offset_encoding: OffsetEncoding,
config: Option<Value>, config: Option<Value>,
root_path: Option<std::path::PathBuf>, root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>, root_uri: Option<lsp::Url>,
workspace_folders: Vec<lsp::WorkspaceFolder>, workspace_folders: Vec<lsp::WorkspaceFolder>,
req_timeout: u64, req_timeout: u64,
@ -42,18 +42,22 @@ pub struct Client {
impl Client { impl Client {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)]
pub fn start( pub fn start(
cmd: &str, cmd: &str,
args: &[String], args: &[String],
config: Option<Value>, config: Option<Value>,
server_environment: HashMap<String, String>,
root_markers: &[String], root_markers: &[String],
id: usize, id: usize,
req_timeout: u64, req_timeout: u64,
doc_path: Option<&std::path::PathBuf>,
) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> { ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> {
// Resolve path to the binary // Resolve path to the binary
let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?; let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?;
let process = Command::new(cmd) let process = Command::new(cmd)
.envs(server_environment)
.args(args) .args(args)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@ -72,11 +76,12 @@ impl Client {
let (server_rx, server_tx, initialize_notify) = let (server_rx, server_tx, initialize_notify) =
Transport::start(reader, writer, stderr, id); Transport::start(reader, writer, stderr, id);
let root_path = find_root(None, root_markers); let root_path = find_root(
doc_path.and_then(|x| x.parent().and_then(|x| x.to_str())),
root_markers,
);
let root_uri = root_path let root_uri = lsp::Url::from_file_path(root_path.clone()).ok();
.clone()
.and_then(|root| lsp::Url::from_file_path(root).ok());
// TODO: support multiple workspace folders // TODO: support multiple workspace folders
let workspace_folders = root_uri let workspace_folders = root_uri
@ -281,10 +286,7 @@ impl Client {
workspace_folders: Some(self.workspace_folders.clone()), workspace_folders: Some(self.workspace_folders.clone()),
// root_path is obsolete, but some clients like pyright still use it so we specify both. // root_path is obsolete, but some clients like pyright still use it so we specify both.
// clients will prefer _uri if possible // clients will prefer _uri if possible
root_path: self root_path: self.root_path.to_str().map(|path| path.to_owned()),
.root_path
.clone()
.and_then(|path| path.to_str().map(|path| path.to_owned())),
root_uri: self.root_uri.clone(), root_uri: self.root_uri.clone(),
initialization_options: self.config.clone(), initialization_options: self.config.clone(),
capabilities: lsp::ClientCapabilities { capabilities: lsp::ClientCapabilities {
@ -299,6 +301,9 @@ impl Client {
dynamic_registration: Some(false), dynamic_registration: Some(false),
..Default::default() ..Default::default()
}), }),
execute_command: Some(lsp::DynamicRegistrationClientCapabilities {
dynamic_registration: Some(false),
}),
..Default::default() ..Default::default()
}), }),
text_document: Some(lsp::TextDocumentClientCapabilities { text_document: Some(lsp::TextDocumentClientCapabilities {
@ -312,6 +317,7 @@ impl Client {
String::from("additionalTextEdits"), String::from("additionalTextEdits"),
], ],
}), }),
insert_replace_support: Some(true),
..Default::default() ..Default::default()
}), }),
completion_item_kind: Some(lsp::CompletionItemKindCapability { completion_item_kind: Some(lsp::CompletionItemKindCapability {
@ -374,7 +380,10 @@ impl Client {
..Default::default() ..Default::default()
}, },
trace: None, 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 locale: None, // TODO
}; };
@ -543,16 +552,17 @@ impl Client {
new_text: &Rope, new_text: &Rope,
changes: &ChangeSet, changes: &ChangeSet,
) -> Option<impl Future<Output = Result<()>>> { ) -> Option<impl Future<Output = Result<()>>> {
// figure out what kind of sync the server supports
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document sync.
let sync_capabilities = match capabilities.text_document_sync { let sync_capabilities = match capabilities.text_document_sync {
Some(lsp::TextDocumentSyncCapability::Kind(kind)) Some(
| Some(lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { lsp::TextDocumentSyncCapability::Kind(kind)
change: Some(kind), | lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions {
.. change: Some(kind),
})) => kind, ..
}),
) => kind,
// None | SyncOptions { changes: None } // None | SyncOptions { changes: None }
_ => return None, _ => return None,
}; };
@ -628,8 +638,12 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
// ) -> Result<Vec<lsp::CompletionItem>> { let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support completion.
capabilities.completion_provider.as_ref()?;
let params = lsp::CompletionParams { let params = lsp::CompletionParams {
text_document_position: lsp::TextDocumentPositionParams { text_document_position: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -644,15 +658,25 @@ impl Client {
// lsp::CompletionContext { trigger_kind: , trigger_character: Some(), } // lsp::CompletionContext { trigger_kind: , trigger_character: Some(), }
}; };
self.call::<lsp::request::Completion>(params) Some(self.call::<lsp::request::Completion>(params))
} }
pub async fn resolve_completion_item( pub fn resolve_completion_item(
&self, &self,
completion_item: lsp::CompletionItem, completion_item: lsp::CompletionItem,
) -> Result<lsp::CompletionItem> { ) -> Option<impl Future<Output = Result<Value>>> {
self.request::<lsp::request::ResolveCompletionItem>(completion_item) let capabilities = self.capabilities.get().unwrap();
.await
// 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::<lsp::request::ResolveCompletionItem>(completion_item))
} }
pub fn text_document_signature_help( pub fn text_document_signature_help(
@ -663,7 +687,7 @@ impl Client {
) -> Option<impl Future<Output = Result<Value>>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap(); 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()?; capabilities.signature_help_provider.as_ref()?;
let params = lsp::SignatureHelpParams { let params = lsp::SignatureHelpParams {
@ -684,7 +708,18 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::HoverParams {
text_document_position_params: lsp::TextDocumentPositionParams { text_document_position_params: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -694,7 +729,7 @@ impl Client {
// lsp::SignatureHelpContext // lsp::SignatureHelpContext
}; };
self.call::<lsp::request::HoverRequest>(params) Some(self.call::<lsp::request::HoverRequest>(params))
} }
// formatting // formatting
@ -707,13 +742,11 @@ impl Client {
) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> { ) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
let capabilities = self.capabilities.get().unwrap(); 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 { match capabilities.document_formatting_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
// None | Some(false)
_ => return None, _ => return None,
}; };
// TODO: return err::unavailable so we can fall back to tree sitter formatting
// merge FormattingOptions with 'config.format' // merge FormattingOptions with 'config.format'
let config_format = self let config_format = self
@ -748,22 +781,20 @@ impl Client {
}) })
} }
pub async fn text_document_range_formatting( pub fn text_document_range_formatting(
&self, &self,
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
range: lsp::Range, range: lsp::Range,
options: lsp::FormattingOptions, options: lsp::FormattingOptions,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> anyhow::Result<Vec<lsp::TextEdit>> { ) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
let capabilities = self.capabilities.get().unwrap(); 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 { match capabilities.document_range_formatting_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
// None | Some(false) _ => return None,
_ => return Ok(Vec::new()),
}; };
// TODO: return err::unavailable so we can fall back to tree sitter formatting
let params = lsp::DocumentRangeFormattingParams { let params = lsp::DocumentRangeFormattingParams {
text_document, text_document,
@ -772,11 +803,13 @@ impl Client {
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
}; };
let response = self let request = self.call::<lsp::request::RangeFormatting>(params);
.request::<lsp::request::RangeFormatting>(params)
.await?;
Ok(response.unwrap_or_default()) Some(async move {
let json = request.await?;
let response: Option<Vec<lsp::TextEdit>> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
} }
pub fn text_document_document_highlight( pub fn text_document_document_highlight(
@ -784,7 +817,15 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::DocumentHighlightParams {
text_document_position_params: lsp::TextDocumentPositionParams { text_document_position_params: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -796,7 +837,7 @@ impl Client {
}, },
}; };
self.call::<lsp::request::DocumentHighlightRequest>(params) Some(self.call::<lsp::request::DocumentHighlightRequest>(params))
} }
fn goto_request< fn goto_request<
@ -829,8 +870,20 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
self.goto_request::<lsp::request::GotoDefinition>(text_document, position, work_done_token) 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::<lsp::request::GotoDefinition>(
text_document,
position,
work_done_token,
))
} }
pub fn goto_type_definition( pub fn goto_type_definition(
@ -838,12 +891,23 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
self.goto_request::<lsp::request::GotoTypeDefinition>( 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::<lsp::request::GotoTypeDefinition>(
text_document, text_document,
position, position,
work_done_token, work_done_token,
) ))
} }
pub fn goto_implementation( pub fn goto_implementation(
@ -851,12 +915,23 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
self.goto_request::<lsp::request::GotoImplementation>( 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::<lsp::request::GotoImplementation>(
text_document, text_document,
position, position,
work_done_token, work_done_token,
) ))
} }
pub fn goto_reference( pub fn goto_reference(
@ -864,7 +939,15 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::ReferenceParams {
text_document_position: lsp::TextDocumentPositionParams { text_document_position: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -879,31 +962,47 @@ impl Client {
}, },
}; };
self.call::<lsp::request::References>(params) Some(self.call::<lsp::request::References>(params))
} }
pub fn document_symbols( pub fn document_symbols(
&self, &self,
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::DocumentSymbolParams {
text_document, text_document,
work_done_progress_params: lsp::WorkDoneProgressParams::default(), work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(), partial_result_params: lsp::PartialResultParams::default(),
}; };
self.call::<lsp::request::DocumentSymbolRequest>(params) Some(self.call::<lsp::request::DocumentSymbolRequest>(params))
} }
// empty string to get all symbols // empty string to get all symbols
pub fn workspace_symbols(&self, query: String) -> impl Future<Output = Result<Value>> { pub fn workspace_symbols(&self, query: String) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::WorkspaceSymbolParams {
query, query,
work_done_progress_params: lsp::WorkDoneProgressParams::default(), work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(), partial_result_params: lsp::PartialResultParams::default(),
}; };
self.call::<lsp::request::WorkspaceSymbol>(params) Some(self.call::<lsp::request::WorkspaceSymbol>(params))
} }
pub fn code_actions( pub fn code_actions(
@ -911,7 +1010,18 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
range: lsp::Range, range: lsp::Range,
context: lsp::CodeActionContext, context: lsp::CodeActionContext,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::CodeActionParams {
text_document, text_document,
range, range,
@ -920,26 +1030,22 @@ impl Client {
partial_result_params: lsp::PartialResultParams::default(), partial_result_params: lsp::PartialResultParams::default(),
}; };
self.call::<lsp::request::CodeActionRequest>(params) Some(self.call::<lsp::request::CodeActionRequest>(params))
} }
pub async fn rename_symbol( pub fn rename_symbol(
&self, &self,
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
new_name: String, new_name: String,
) -> anyhow::Result<lsp::WorkspaceEdit> { ) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
let capabilities = self.capabilities.get().unwrap(); 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 { match capabilities.rename_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (),
// None | Some(false) // None | Some(false)
_ => { _ => return None,
log::warn!("rename_symbol failed: The server does not support rename");
let err = "The server does not support rename";
return Err(anyhow!(err));
}
}; };
let params = lsp::RenameParams { let params = lsp::RenameParams {
@ -953,11 +1059,21 @@ impl Client {
}, },
}; };
let response = self.request::<lsp::request::Rename>(params).await?; let request = self.call::<lsp::request::Rename>(params);
Ok(response.unwrap_or_default())
Some(async move {
let json = request.await?;
let response: Option<lsp::WorkspaceEdit> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
} }
pub fn command(&self, command: lsp::Command) -> impl Future<Output = Result<Value>> { pub fn command(&self, command: lsp::Command) -> Option<impl Future<Output = Result<Value>>> {
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 { let params = lsp::ExecuteCommandParams {
command: command.command, command: command.command,
arguments: command.arguments.unwrap_or_default(), arguments: command.arguments.unwrap_or_default(),
@ -966,6 +1082,6 @@ impl Client {
}, },
}; };
self.call::<lsp::request::ExecuteCommand>(params) Some(self.call::<lsp::request::ExecuteCommand>(params))
} }
} }

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
// https://www.jsonrpc.org/specification#error_object // https://www.jsonrpc.org/specification#error_object
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Eq, Clone)]
pub enum ErrorCode { pub enum ErrorCode {
ParseError, ParseError,
InvalidRequest, InvalidRequest,
@ -68,7 +68,7 @@ impl Serialize for ErrorCode {
} }
} }
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Error { pub struct Error {
pub code: ErrorCode, pub code: ErrorCode,
pub message: String, pub message: String,
@ -100,7 +100,7 @@ impl std::error::Error for Error {}
// https://www.jsonrpc.org/specification#request_object // https://www.jsonrpc.org/specification#request_object
/// Request ID /// Request ID
#[derive(Debug, PartialEq, Clone, Hash, Eq, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Hash, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Id { pub enum Id {
Null, Null,
@ -109,7 +109,7 @@ pub enum Id {
} }
/// Protocol Version /// Protocol Version
#[derive(Debug, PartialEq, Clone, Copy, Hash, Eq)] #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub enum Version { pub enum Version {
V2, V2,
} }
@ -153,7 +153,7 @@ impl<'de> Deserialize<'de> for Version {
} }
} }
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Params { pub enum Params {
None, None,
@ -182,7 +182,7 @@ impl From<Params> for Value {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct MethodCall { pub struct MethodCall {
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
@ -192,7 +192,7 @@ pub struct MethodCall {
pub id: Id, pub id: Id,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Notification { pub struct Notification {
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
@ -201,7 +201,7 @@ pub struct Notification {
pub params: Params, pub params: Params,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[serde(untagged)] #[serde(untagged)]
pub enum Call { pub enum Call {
@ -235,7 +235,7 @@ impl From<Notification> for Call {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[serde(untagged)] #[serde(untagged)]
pub enum Request { pub enum Request {
@ -245,7 +245,7 @@ pub enum Request {
// https://www.jsonrpc.org/specification#response_object // https://www.jsonrpc.org/specification#response_object
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Success { pub struct Success {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
@ -253,7 +253,7 @@ pub struct Success {
pub id: Id, pub id: Id,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
pub struct Failure { pub struct Failure {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
@ -264,7 +264,7 @@ pub struct Failure {
// Note that failure comes first because we're not using // Note that failure comes first because we're not using
// #[serde(deny_unknown_field)]: we want a request that contains // #[serde(deny_unknown_field)]: we want a request that contains
// both `result` and `error` to be a `Failure`. // both `result` and `error` to be a `Failure`.
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Output { pub enum Output {
Failure(Failure), Failure(Failure),
@ -280,7 +280,7 @@ impl From<Output> for Result<Value, Error> {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Response { pub enum Response {
Single(Output), Single(Output),

@ -9,7 +9,8 @@ pub use lsp::{Position, Url};
pub use lsp_types as lsp; pub use lsp_types as lsp;
use futures_util::stream::select_all::SelectAll; use futures_util::stream::select_all::SelectAll;
use helix_core::syntax::LanguageConfiguration; use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration};
use tokio::sync::mpsc::UnboundedReceiver;
use std::{ use std::{
collections::{hash_map::Entry, HashMap}, collections::{hash_map::Entry, HashMap},
@ -56,7 +57,7 @@ pub enum OffsetEncoding {
pub mod util { pub mod util {
use super::*; 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`]. /// Converts a diagnostic in the document to [`lsp::Diagnostic`].
/// ///
@ -84,16 +85,34 @@ pub mod util {
None => None, None => None,
}; };
// TODO: add support for Diagnostic.data let new_tags: Vec<_> = diag
lsp::Diagnostic::new( .tags
range_to_lsp_range(doc, range, offset_encoding), .iter()
.map(|tag| match tag {
helix_core::diagnostic::DiagnosticTag::Unnecessary => {
lsp::DiagnosticTag::UNNECESSARY
}
helix_core::diagnostic::DiagnosticTag::Deprecated => lsp::DiagnosticTag::DEPRECATED,
})
.collect();
let tags = if !new_tags.is_empty() {
Some(new_tags)
} else {
None
};
lsp::Diagnostic {
range: range_to_lsp_range(doc, range, offset_encoding),
severity, severity,
code, code,
None, source: diag.source.clone(),
diag.message.to_owned(), message: diag.message.to_owned(),
None, related_information: None,
None, tags,
) data: diag.data.to_owned(),
..Default::default()
}
} }
/// Converts [`lsp::Position`] to a position in the document. /// Converts [`lsp::Position`] to a position in the document.
@ -177,6 +196,42 @@ pub mod util {
Some(Range::new(start, end)) 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<Tendril> = 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( pub fn generate_transaction_from_edits(
doc: &Rope, doc: &Rope,
mut edits: Vec<lsp::TextEdit>, mut edits: Vec<lsp::TextEdit>,
@ -186,6 +241,20 @@ pub mod util {
// in reverse order. // in reverse order.
edits.sort_unstable_by_key(|edit| edit.range.start); edits.sort_unstable_by_key(|edit| edit.range.start);
// Generate a diff if the edit is a full document replacement.
#[allow(clippy::collapsible_if)]
if edits.len() == 1 {
let is_document_replacement = edits.first().and_then(|edit| {
let start = lsp_pos_to_pos(doc, edit.range.start, offset_encoding)?;
let end = lsp_pos_to_pos(doc, edit.range.end, offset_encoding)?;
Some(start..end)
}) == Some(0..doc.len_chars());
if is_document_replacement {
let new_text = Rope::from(edits.pop().unwrap().new_text);
return helix_core::diff::compare_ropes(doc, &new_text);
}
}
Transaction::change( Transaction::change(
doc, doc,
edits.into_iter().map(|edit| { edits.into_iter().map(|edit| {
@ -250,6 +319,8 @@ impl MethodCall {
pub enum Notification { pub enum Notification {
// we inject this notification to signal the LSP is ready // we inject this notification to signal the LSP is ready
Initialized, Initialized,
// and this notification to signal that the LSP exited
Exit,
PublishDiagnostics(lsp::PublishDiagnosticsParams), PublishDiagnostics(lsp::PublishDiagnosticsParams),
ShowMessage(lsp::ShowMessageParams), ShowMessage(lsp::ShowMessageParams),
LogMessage(lsp::LogMessageParams), LogMessage(lsp::LogMessageParams),
@ -262,6 +333,7 @@ impl Notification {
let notification = match method { let notification = match method {
lsp::notification::Initialized::METHOD => Self::Initialized, lsp::notification::Initialized::METHOD => Self::Initialized,
lsp::notification::Exit::METHOD => Self::Exit,
lsp::notification::PublishDiagnostics::METHOD => { lsp::notification::PublishDiagnostics::METHOD => {
let params: lsp::PublishDiagnosticsParams = params.parse()?; let params: lsp::PublishDiagnosticsParams = params.parse()?;
Self::PublishDiagnostics(params) Self::PublishDiagnostics(params)
@ -318,55 +390,63 @@ impl Registry {
.map(|(_, client)| client.as_ref()) .map(|(_, client)| client.as_ref())
} }
pub fn get(&mut self, language_config: &LanguageConfiguration) -> Result<Option<Arc<Client>>> { pub fn remove_by_id(&mut self, id: usize) {
self.inner.retain(|_, (client_id, _)| client_id != &id)
}
pub fn restart(
&mut self,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<Option<Arc<Client>>> {
let config = match &language_config.language_server { let config = match &language_config.language_server {
Some(config) => config, Some(config) => config,
None => return Ok(None), None => return Ok(None),
}; };
match self.inner.entry(language_config.scope.clone()) { let scope = language_config.scope.clone();
Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())),
Entry::Vacant(entry) => { match self.inner.entry(scope) {
Entry::Vacant(_) => Ok(None),
Entry::Occupied(mut entry) => {
// initialize a new client // initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed); let id = self.counter.fetch_add(1, Ordering::Relaxed);
let (client, incoming, initialize_notify) = Client::start(
&config.command, let NewClientResult(client, incoming) =
&config.args, start_client(id, language_config, config, doc_path)?;
language_config.config.clone(),
&language_config.roots,
id,
config.timeout,
)?;
self.incoming.push(UnboundedReceiverStream::new(incoming)); self.incoming.push(UnboundedReceiverStream::new(incoming));
let client = Arc::new(client);
// Initialize the client asynchronously let (_, old_client) = entry.insert((id, client.clone()));
let _client = client.clone();
tokio::spawn(async move { tokio::spawn(async move {
use futures_util::TryFutureExt; let _ = old_client.force_shutdown().await;
let value = _client
.capabilities
.get_or_try_init(|| {
_client
.initialize()
.map_ok(|response| response.capabilities)
})
.await;
if let Err(e) = value {
log::error!("failed to initialize language server: {}", e);
return;
}
// next up, notify<initialized>
_client
.notify::<lsp::notification::Initialized>(lsp::InitializedParams {})
.await
.unwrap();
initialize_notify.notify_one();
}); });
Ok(Some(client))
}
}
}
pub fn get(
&mut self,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<Option<Arc<Client>>> {
let config = match &language_config.language_server {
Some(config) => config,
None => return Ok(None),
};
match self.inner.entry(language_config.scope.clone()) {
Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())),
Entry::Vacant(entry) => {
// initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed);
let NewClientResult(client, incoming) =
start_client(id, language_config, config, doc_path)?;
self.incoming.push(UnboundedReceiverStream::new(incoming));
entry.insert((id, client.clone())); entry.insert((id, client.clone()));
Ok(Some(client)) Ok(Some(client))
} }
@ -456,6 +536,59 @@ impl LspProgressMap {
} }
} }
struct NewClientResult(Arc<Client>, UnboundedReceiver<(usize, Call)>);
/// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that
/// it is only called when it makes sense.
fn start_client(
id: usize,
config: &LanguageConfiguration,
ls_config: &LanguageServerConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<NewClientResult> {
let (client, incoming, initialize_notify) = Client::start(
&ls_config.command,
&ls_config.args,
config.config.clone(),
ls_config.environment.clone(),
&config.roots,
id,
ls_config.timeout,
doc_path,
)?;
let client = Arc::new(client);
// Initialize the client asynchronously
let _client = client.clone();
tokio::spawn(async move {
use futures_util::TryFutureExt;
let value = _client
.capabilities
.get_or_try_init(|| {
_client
.initialize()
.map_ok(|response| response.capabilities)
})
.await;
if let Err(e) = value {
log::error!("failed to initialize language server: {}", e);
return;
}
// next up, notify<initialized>
_client
.notify::<lsp::notification::Initialized>(lsp::InitializedParams {})
.await
.unwrap();
initialize_notify.notify_one();
});
Ok(NewClientResult(client, incoming))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{lsp, util::*, OffsetEncoding}; use super::{lsp, util::*, OffsetEncoding};

@ -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) => { Err(err) => {
error!("err: <- {:?}", err); error!("err: <- {:?}", err);
break; break;

@ -17,8 +17,10 @@ build = true
app = true app = true
[features] [features]
default = ["git"]
unicode-lines = ["helix-core/unicode-lines"] unicode-lines = ["helix-core/unicode-lines"]
integration = [] integration = []
git = ["helix-vcs/git"]
[[bin]] [[bin]]
name = "hx" name = "hx"
@ -29,10 +31,11 @@ helix-core = { version = "0.6", path = "../helix-core" }
helix-view = { version = "0.6", path = "../helix-view" } helix-view = { version = "0.6", path = "../helix-view" }
helix-lsp = { version = "0.6", path = "../helix-lsp" } helix-lsp = { version = "0.6", path = "../helix-lsp" }
helix-dap = { version = "0.6", path = "../helix-dap" } helix-dap = { version = "0.6", path = "../helix-dap" }
helix-vcs = { version = "0.6", path = "../helix-vcs" }
helix-loader = { version = "0.6", path = "../helix-loader" } helix-loader = { version = "0.6", path = "../helix-loader" }
anyhow = "1" anyhow = "1"
once_cell = "1.14" once_cell = "1.16"
which = "4.2" which = "4.2"
@ -67,9 +70,6 @@ serde = { version = "1.0", features = ["derive"] }
grep-regex = "0.1.10" grep-regex = "0.1.10"
grep-searcher = "0.1.10" grep-searcher = "0.1.10"
# Remove once retain_mut lands in stable rust
retain_mut = "0.1.7"
[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
@ -77,6 +77,6 @@ signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
helix-loader = { version = "0.6", path = "../helix-loader" } helix-loader = { version = "0.6", path = "../helix-loader" }
[dev-dependencies] [dev-dependencies]
smallvec = "1.9" smallvec = "1.10"
indoc = "1.0.6" indoc = "1.0.8"
tempfile = "3.3.0" tempfile = "3.3.0"

@ -1,30 +1,9 @@
use helix_loader::grammar::{build_grammars, fetch_grammars}; use helix_loader::grammar::{build_grammars, fetch_grammars};
use std::borrow::Cow;
use std::process::Command;
const VERSION: &str = include_str!("../VERSION");
fn main() { 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() { if std::env::var("HELIX_DISABLE_AUTO_GRAMMAR_BUILD").is_err() {
fetch_grammars().expect("Failed to fetch tree-sitter grammars"); fetch_grammars().expect("Failed to fetch tree-sitter grammars");
build_grammars(Some(std::env::var("TARGET").unwrap())) build_grammars(Some(std::env::var("TARGET").unwrap()))
.expect("Failed to compile tree-sitter grammars"); .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);
} }

@ -1,13 +1,22 @@
use arc_swap::{access::Map, ArcSwap}; use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream; use futures_util::Stream;
use helix_core::{ use helix_core::{
config::{default_syntax_loader, user_syntax_loader}, diagnostic::{DiagnosticTag, NumberOrString},
diagnostic::NumberOrString, path::get_relative_path,
pos_at_coords, syntax, Selection, pos_at_coords, syntax, Selection,
}; };
use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap};
use helix_view::{align_view, editor::ConfigEvent, theme, tree::Layout, Align, Editor}; use helix_view::{
align_view,
document::DocumentSavedEventResult,
editor::{ConfigEvent, EditorEvent},
graphics::Rect,
theme,
tree::Layout,
Align, Editor,
};
use serde_json::json; use serde_json::json;
use tui::backend::Backend;
use crate::{ use crate::{
args::Args, args::Args,
@ -19,7 +28,7 @@ use crate::{
ui::{self, overlay::overlayed}, ui::{self, overlay::overlayed},
}; };
use log::{error, warn}; use log::{debug, error, warn};
use std::{ use std::{
io::{stdin, stdout, Write}, io::{stdin, stdout, Write},
sync::Arc, sync::Arc,
@ -30,8 +39,8 @@ use anyhow::{Context, Error};
use crossterm::{ use crossterm::{
event::{ event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
Event as CrosstermEvent, EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent,
}, },
execute, terminal, execute, terminal,
tty::IsTty, tty::IsTty,
@ -46,8 +55,21 @@ type Signals = futures_util::stream::Empty<()>;
const LSP_DEADLINE: Duration = Duration::from_millis(16); const LSP_DEADLINE: Duration = Duration::from_millis(16);
#[cfg(not(feature = "integration"))]
use tui::backend::CrosstermBackend;
#[cfg(feature = "integration")]
use tui::backend::TestBackend;
#[cfg(not(feature = "integration"))]
type Terminal = tui::terminal::Terminal<CrosstermBackend<std::io::Stdout>>;
#[cfg(feature = "integration")]
type Terminal = tui::terminal::Terminal<TestBackend>;
pub struct Application { pub struct Application {
compositor: Compositor, compositor: Compositor,
terminal: Terminal,
pub editor: Editor, pub editor: Editor,
config: Arc<ArcSwap<Config>>, config: Arc<ArcSwap<Config>>,
@ -95,6 +117,7 @@ fn restore_term() -> Result<(), Error> {
execute!( execute!(
stdout, stdout,
DisableBracketedPaste, DisableBracketedPaste,
DisableFocusChange,
terminal::LeaveAlternateScreen terminal::LeaveAlternateScreen
)?; )?;
terminal::disable_raw_mode()?; terminal::disable_raw_mode()?;
@ -102,7 +125,11 @@ fn restore_term() -> Result<(), Error> {
} }
impl Application { impl Application {
pub fn new(args: Args, config: Config) -> Result<Self, Error> { pub fn new(
args: Args,
config: Config,
syn_loader_conf: syntax::Configuration,
) -> Result<Self, Error> {
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
setup_integration_logging(); setup_integration_logging();
@ -129,20 +156,20 @@ impl Application {
}) })
.unwrap_or_else(|| theme_loader.default_theme(true_color)); .unwrap_or_else(|| theme_loader.default_theme(true_color));
let syn_loader_conf = user_syntax_loader().unwrap_or_else(|err| {
eprintln!("Bad language config: {}", err);
eprintln!("Press <ENTER> to continue with default language config");
use std::io::Read;
// This waits for an enter press.
let _ = std::io::stdin().read(&mut []);
default_syntax_loader()
});
let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf));
let mut compositor = Compositor::new().context("build compositor")?; #[cfg(not(feature = "integration"))]
let backend = CrosstermBackend::new(stdout());
#[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150);
let terminal = Terminal::new(backend)?;
let area = terminal.size().expect("couldn't get terminal size");
let mut compositor = Compositor::new(area);
let config = Arc::new(ArcSwap::from_pointee(config)); let config = Arc::new(ArcSwap::from_pointee(config));
let mut editor = Editor::new( let mut editor = Editor::new(
compositor.size(), area,
theme_loader.clone(), theme_loader.clone(),
syn_loader.clone(), syn_loader.clone(),
Box::new(Map::new(Arc::clone(&config), |config: &Config| { Box::new(Map::new(Arc::clone(&config), |config: &Config| {
@ -164,7 +191,7 @@ impl Application {
} else if !args.files.is_empty() { } else if !args.files.is_empty() {
let first = &args.files[0].0; // we know it's not empty let first = &args.files[0].0; // we know it's not empty
if first.is_dir() { if first.is_dir() {
std::env::set_current_dir(&first).context("set current dir")?; std::env::set_current_dir(first).context("set current dir")?;
editor.new_file(Action::VerticalSplit); editor.new_file(Action::VerticalSplit);
let picker = ui::file_picker(".".into(), &config.load().editor); let picker = ui::file_picker(".".into(), &config.load().editor);
compositor.push(Box::new(overlayed(picker))); compositor.push(Box::new(overlayed(picker)));
@ -200,7 +227,11 @@ impl Application {
doc.set_selection(view_id, pos); 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, // align the view to center after all files are loaded,
// does not affect views without pos since it is at the top // does not affect views without pos since it is at the top
let (view, doc) = current!(editor); let (view, doc) = current!(editor);
@ -224,11 +255,12 @@ impl Application {
#[cfg(windows)] #[cfg(windows)]
let signals = futures_util::stream::empty(); let signals = futures_util::stream::empty();
#[cfg(not(windows))] #[cfg(not(windows))]
let signals = let signals = Signals::new([signal::SIGTSTP, signal::SIGCONT, signal::SIGUSR1])
Signals::new(&[signal::SIGTSTP, signal::SIGCONT]).context("build signal handler")?; .context("build signal handler")?;
let app = Self { let app = Self {
compositor, compositor,
terminal,
editor, editor,
config, config,
@ -245,23 +277,49 @@ impl Application {
Ok(app) Ok(app)
} }
fn render(&mut self) { #[cfg(feature = "integration")]
let compositor = &mut self.compositor; async fn render(&mut self) {}
#[cfg(not(feature = "integration"))]
async fn render(&mut self) {
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
scroll: None, scroll: None,
}; };
compositor.render(&mut cx); // Acquire mutable access to the redraw_handle lock
// to ensure that there are no tasks running that want to block rendering
drop(cx.editor.redraw_handle.1.write().await);
cx.editor.needs_redraw = false;
{
// exhaust any leftover redraw notifications
let notify = cx.editor.redraw_handle.0.notified();
tokio::pin!(notify);
notify.enable();
}
let area = self
.terminal
.autoresize()
.expect("Unable to determine terminal size");
// TODO: need to recalculate view tree if necessary
let surface = self.terminal.current_buffer_mut();
self.compositor.render(area, surface, &mut cx);
let (pos, kind) = self.compositor.cursor(area, &self.editor);
let pos = pos.map(|pos| (pos.col as u16, pos.row as u16));
self.terminal.draw(pos, kind).unwrap();
} }
pub async fn event_loop<S>(&mut self, input_stream: &mut S) pub async fn event_loop<S>(&mut self, input_stream: &mut S)
where where
S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin,
{ {
self.render(); self.render().await;
self.last_render = Instant::now(); self.last_render = Instant::now();
loop { loop {
@ -275,9 +333,6 @@ impl Application {
where where
S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin,
{ {
#[cfg(feature = "integration")]
let mut idle_handled = false;
loop { loop {
if self.editor.should_close() { if self.editor.should_close() {
return false; return false;
@ -289,59 +344,35 @@ impl Application {
biased; biased;
Some(event) = input_stream.next() => { Some(event) = input_stream.next() => {
self.handle_terminal_events(event); self.handle_terminal_events(event).await;
} }
Some(signal) = self.signals.next() => { Some(signal) = self.signals.next() => {
self.handle_signals(signal).await; self.handle_signals(signal).await;
} }
Some((id, call)) = self.editor.language_servers.incoming.next() => {
self.handle_language_server_message(call, id).await;
// limit render calls for fast language server messages
let last = self.editor.language_servers.incoming.is_empty();
if last || self.last_render.elapsed() > LSP_DEADLINE {
self.render();
self.last_render = Instant::now();
}
}
Some(payload) = self.editor.debugger_events.next() => {
let needs_render = self.editor.handle_debugger_message(payload).await;
if needs_render {
self.render();
}
}
Some(config_event) = self.editor.config_events.1.recv() => {
self.handle_config_events(config_event);
self.render();
}
Some(callback) = self.jobs.futures.next() => { Some(callback) = self.jobs.futures.next() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback);
self.render(); self.render().await;
} }
Some(callback) = self.jobs.wait_futures.next() => { Some(callback) = self.jobs.wait_futures.next() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback);
self.render(); self.render().await;
} }
_ = &mut self.editor.idle_timer => { event = self.editor.wait_event() => {
// idle timeout let _idle_handled = self.handle_editor_event(event).await;
self.editor.clear_idle_timer();
self.handle_idle_timeout();
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
{ {
idle_handled = true; if _idle_handled {
return true;
}
} }
} }
} }
// for integration tests only, reset the idle timer after every // for integration tests only, reset the idle timer after every
// event to make a signal when test events are done processing // event to signal when test events are done processing
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
{ {
if idle_handled {
return true;
}
self.editor.reset_idle_timer(); self.editor.reset_idle_timer();
} }
} }
@ -411,43 +442,155 @@ impl Application {
#[cfg(not(windows))] #[cfg(not(windows))]
pub async fn handle_signals(&mut self, signal: i32) { pub async fn handle_signals(&mut self, signal: i32) {
use helix_view::graphics::Rect;
match signal { match signal {
signal::SIGTSTP => { signal::SIGTSTP => {
self.compositor.save_cursor(); // restore cursor
use helix_view::graphics::CursorKind;
self.terminal
.backend_mut()
.show_cursor(CursorKind::Block)
.ok();
restore_term().unwrap(); restore_term().unwrap();
low_level::emulate_default_handler(signal::SIGTSTP).unwrap(); low_level::emulate_default_handler(signal::SIGTSTP).unwrap();
} }
signal::SIGCONT => { signal::SIGCONT => {
self.claim_term().await.unwrap(); self.claim_term().await.unwrap();
// redraw the terminal // redraw the terminal
let Rect { width, height, .. } = self.compositor.size(); let area = self.terminal.size().expect("couldn't get terminal size");
self.compositor.resize(width, height); self.compositor.resize(area);
self.compositor.load_cursor(); self.terminal.clear().expect("couldn't clear terminal");
self.render();
self.render().await;
}
signal::SIGUSR1 => {
self.refresh_config();
self.render().await;
} }
_ => unreachable!(), _ => unreachable!(),
} }
} }
pub fn handle_idle_timeout(&mut self) { pub async fn handle_idle_timeout(&mut self) {
use crate::compositor::EventResult;
let editor_view = self
.compositor
.find::<ui::EditorView>()
.expect("expected at least one EditorView");
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
scroll: None, scroll: None,
}; };
if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx);
self.render(); if should_render || self.editor.needs_redraw {
self.render().await;
} }
} }
pub fn handle_terminal_events(&mut self, event: Result<CrosstermEvent, crossterm::ErrorKind>) { pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) {
let doc_save_event = match doc_save_event {
Ok(event) => event,
Err(err) => {
self.editor.set_error(err.to_string());
return;
}
};
let doc = match self.editor.document_mut(doc_save_event.doc_id) {
None => {
warn!(
"received document saved event for non-existent doc id: {}",
doc_save_event.doc_id
);
return;
}
Some(doc) => doc,
};
debug!(
"document {:?} saved with revision {}",
doc.path(),
doc_save_event.revision
);
doc.set_last_saved_revision(doc_save_event.revision);
let lines = doc_save_event.text.len_lines();
let bytes = doc_save_event.text.len_bytes();
if doc.path() != Some(&doc_save_event.path) {
if let Err(err) = doc.set_path(Some(&doc_save_event.path)) {
log::error!(
"error setting path for doc '{:?}': {}",
doc.path(),
err.to_string(),
);
self.editor.set_error(err.to_string());
return;
}
let loader = self.editor.syn_loader.clone();
// borrowing the same doc again to get around the borrow checker
let doc = doc_mut!(self.editor, &doc_save_event.doc_id);
let id = doc.id();
doc.detect_language(loader);
let _ = self.editor.refresh_language_server(id);
}
// TODO: fix being overwritten by lsp
self.editor.set_status(format!(
"'{}' written, {}L {}B",
get_relative_path(&doc_save_event.path).to_string_lossy(),
lines,
bytes
));
}
#[inline(always)]
pub async fn handle_editor_event(&mut self, event: EditorEvent) -> bool {
log::debug!("received editor event: {:?}", event);
match event {
EditorEvent::DocumentSaved(event) => {
self.handle_document_write(event);
self.render().await;
}
EditorEvent::ConfigEvent(event) => {
self.handle_config_events(event);
self.render().await;
}
EditorEvent::LanguageServerMessage((id, call)) => {
self.handle_language_server_message(call, id).await;
// limit render calls for fast language server messages
let last = self.editor.language_servers.incoming.is_empty();
if last || self.last_render.elapsed() > LSP_DEADLINE {
self.render().await;
self.last_render = Instant::now();
}
}
EditorEvent::DebuggerEvent(payload) => {
let needs_render = self.editor.handle_debugger_message(payload).await;
if needs_render {
self.render().await;
}
}
EditorEvent::IdleTimer => {
self.editor.clear_idle_timer();
self.handle_idle_timeout().await;
#[cfg(feature = "integration")]
{
return true;
}
}
}
false
}
pub async fn handle_terminal_events(
&mut self,
event: Result<CrosstermEvent, crossterm::ErrorKind>,
) {
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
@ -456,7 +599,14 @@ impl Application {
// Handle key events // Handle key events
let should_redraw = match event.unwrap() { let should_redraw = match event.unwrap() {
CrosstermEvent::Resize(width, height) => { CrosstermEvent::Resize(width, height) => {
self.compositor.resize(width, height); self.terminal
.resize(Rect::new(0, 0, width, height))
.expect("Unable to resize terminal");
let area = self.terminal.size().expect("couldn't get terminal size");
self.compositor.resize(area);
self.compositor self.compositor
.handle_event(&Event::Resize(width, height), &mut cx) .handle_event(&Event::Resize(width, height), &mut cx)
} }
@ -464,7 +614,7 @@ impl Application {
}; };
if should_redraw && !self.editor.should_close() { if should_redraw && !self.editor.should_close() {
self.render(); self.render().await;
} }
} }
@ -512,14 +662,14 @@ impl Application {
// trigger textDocument/didOpen for docs that are already open // trigger textDocument/didOpen for docs that are already open
for doc in docs { for doc in docs {
let language_id =
doc.language_id().map(ToOwned::to_owned).unwrap_or_default();
let url = match doc.url() { let url = match doc.url() {
Some(url) => url, Some(url) => url,
None => continue, // skip documents with no path None => continue, // skip documents with no path
}; };
let language_id =
doc.language_id().map(ToOwned::to_owned).unwrap_or_default();
tokio::spawn(language_server.text_document_did_open( tokio::spawn(language_server.text_document_did_open(
url, url,
doc.version(), doc.version(),
@ -605,13 +755,29 @@ impl Application {
None => None, None => None,
}; };
let tags = if let Some(ref tags) = diagnostic.tags {
let new_tags = tags.iter().filter_map(|tag| {
match *tag {
lsp::DiagnosticTag::DEPRECATED => Some(DiagnosticTag::Deprecated),
lsp::DiagnosticTag::UNNECESSARY => Some(DiagnosticTag::Unnecessary),
_ => None
}
}).collect();
new_tags
} else {
Vec::new()
};
Some(Diagnostic { Some(Diagnostic {
range: Range { start, end }, range: Range { start, end },
line: diagnostic.range.start.line as usize, line: diagnostic.range.start.line as usize,
message: diagnostic.message.clone(), message: diagnostic.message.clone(),
severity, severity,
code, code,
// source tags,
source: diagnostic.source.clone(),
data: diagnostic.data.clone(),
}) })
}) })
.collect(); .collect();
@ -724,6 +890,32 @@ impl Application {
Notification::ProgressMessage(_params) => { Notification::ProgressMessage(_params) => {
// do nothing // 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 { Call::MethodCall(helix_lsp::jsonrpc::MethodCall {
@ -824,9 +1016,18 @@ impl Application {
} }
async fn claim_term(&mut self) -> Result<(), Error> { async fn claim_term(&mut self) -> Result<(), Error> {
use helix_view::graphics::CursorKind;
terminal::enable_raw_mode()?; terminal::enable_raw_mode()?;
if self.terminal.cursor_kind() == CursorKind::Hidden {
self.terminal.backend_mut().hide_cursor().ok();
}
let mut stdout = stdout(); let mut stdout = stdout();
execute!(stdout, terminal::EnterAlternateScreen, EnableBracketedPaste)?; execute!(
stdout,
terminal::EnterAlternateScreen,
EnableBracketedPaste,
EnableFocusChange
)?;
execute!(stdout, terminal::Clear(terminal::ClearType::All))?; execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
if self.config.load().editor.mouse { if self.config.load().editor.mouse {
execute!(stdout, EnableMouseCapture)?; execute!(stdout, EnableMouseCapture)?;
@ -851,19 +1052,52 @@ impl Application {
})); }));
self.event_loop(input_stream).await; self.event_loop(input_stream).await;
self.close().await?;
let close_errs = self.close().await;
// restore cursor
use helix_view::graphics::CursorKind;
self.terminal
.backend_mut()
.show_cursor(CursorKind::Block)
.ok();
restore_term()?; restore_term()?;
for err in close_errs {
self.editor.exit_code = 1;
eprintln!("Error: {}", err);
}
Ok(self.editor.exit_code) Ok(self.editor.exit_code)
} }
pub async fn close(&mut self) -> anyhow::Result<()> { pub async fn close(&mut self) -> Vec<anyhow::Error> {
self.jobs.finish().await?; // [NOTE] we intentionally do not return early for errors because we
// want to try to run as much cleanup as we can, regardless of
// errors along the way
let mut errs = Vec::new();
if let Err(err) = self
.jobs
.finish(&mut self.editor, Some(&mut self.compositor))
.await
{
log::error!("Error executing job: {}", err);
errs.push(err);
};
if let Err(err) = self.editor.flush_writes().await {
log::error!("Error writing: {}", err);
errs.push(err);
}
if self.editor.close_language_servers(None).await.is_err() { if self.editor.close_language_servers(None).await.is_err() {
log::error!("Timed out waiting for language servers to shutdown"); log::error!("Timed out waiting for language servers to shutdown");
}; errs.push(anyhow::format_err!(
"Timed out waiting for language servers to shutdown"
));
}
Ok(()) errs
} }
} }

@ -14,6 +14,7 @@ pub struct Args {
pub build_grammars: bool, pub build_grammars: bool,
pub split: Option<Layout>, pub split: Option<Layout>,
pub verbosity: u64, pub verbosity: u64,
pub log_file: Option<PathBuf>,
pub config_file: Option<PathBuf>, pub config_file: Option<PathBuf>,
pub files: Vec<(PathBuf, Position)>, pub files: Vec<(PathBuf, Position)>,
} }
@ -31,8 +32,14 @@ impl Args {
"--version" => args.display_version = true, "--version" => args.display_version = true,
"--help" => args.display_help = true, "--help" => args.display_help = true,
"--tutor" => args.load_tutor = true, "--tutor" => args.load_tutor = true,
"--vsplit" => args.split = Some(Layout::Vertical), "--vsplit" => match args.split {
"--hsplit" => args.split = Some(Layout::Horizontal), Some(_) => anyhow::bail!("can only set a split once of a specific type"),
None => args.split = Some(Layout::Vertical),
},
"--hsplit" => match args.split {
Some(_) => anyhow::bail!("can only set a split once of a specific type"),
None => args.split = Some(Layout::Horizontal),
},
"--health" => { "--health" => {
args.health = true; args.health = true;
args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); args.health_arg = argv.next_if(|opt| !opt.starts_with('-'));
@ -48,6 +55,10 @@ impl Args {
Some(path) => args.config_file = Some(path.into()), Some(path) => args.config_file = Some(path.into()),
None => anyhow::bail!("--config must specify a path to read"), None => anyhow::bail!("--config must specify a path to read"),
}, },
"--log" => match argv.next().as_deref() {
Some(path) => args.log_file = Some(path.into()),
None => anyhow::bail!("--log must specify a path to write"),
},
arg if arg.starts_with("--") => { arg if arg.starts_with("--") => {
anyhow::bail!("unexpected double dash argument: {}", arg) anyhow::bail!("unexpected double dash argument: {}", arg)
} }

File diff suppressed because it is too large Load Diff

@ -85,7 +85,7 @@ fn thread_picker(
frame.line.saturating_sub(1), frame.line.saturating_sub(1),
frame.end_line.unwrap_or(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)); compositor.push(Box::new(picker));
@ -118,11 +118,14 @@ fn dap_callback<T, F>(
let callback = Box::pin(async move { let callback = Box::pin(async move {
let json = call.await?; let json = call.await?;
let response = serde_json::from_value(json)?; let response = serde_json::from_value(json)?;
let call: Callback = Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { let call: Callback = Callback::EditorCompositor(Box::new(
callback(editor, compositor, response) move |editor: &mut Editor, compositor: &mut Compositor| {
}); callback(editor, compositor, response)
},
));
Ok(call) Ok(call)
}); });
jobs.callback(callback); jobs.callback(callback);
} }
@ -274,10 +277,11 @@ pub fn dap_launch(cx: &mut Context) {
let completions = template.completion.clone(); let completions = template.completion.clone();
let name = template.name.clone(); let name = template.name.clone();
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |_editor, compositor| { let call: Callback =
let prompt = debug_parameter_prompt(completions, name, Vec::new()); Callback::EditorCompositor(Box::new(move |_editor, compositor| {
compositor.push(Box::new(prompt)); let prompt = debug_parameter_prompt(completions, name, Vec::new());
}); compositor.push(Box::new(prompt));
}));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -332,10 +336,11 @@ fn debug_parameter_prompt(
let config_name = config_name.clone(); let config_name = config_name.clone();
let params = params.clone(); let params = params.clone();
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |_editor, compositor| { let call: Callback =
let prompt = debug_parameter_prompt(completions, config_name, params); Callback::EditorCompositor(Box::new(move |_editor, compositor| {
compositor.push(Box::new(prompt)); let prompt = debug_parameter_prompt(completions, config_name, params);
}); compositor.push(Box::new(prompt));
}));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -582,7 +587,7 @@ pub fn dap_edit_condition(cx: &mut Context) {
None => return, None => return,
}; };
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |editor, compositor| { let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
let mut prompt = Prompt::new( let mut prompt = Prompt::new(
"condition:".into(), "condition:".into(),
None, None,
@ -610,7 +615,7 @@ pub fn dap_edit_condition(cx: &mut Context) {
prompt.insert_str(&condition, editor) prompt.insert_str(&condition, editor)
} }
compositor.push(Box::new(prompt)); compositor.push(Box::new(prompt));
}); }));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -624,7 +629,7 @@ pub fn dap_edit_log(cx: &mut Context) {
None => return, None => return,
}; };
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |editor, compositor| { let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
let mut prompt = Prompt::new( let mut prompt = Prompt::new(
"log-message:".into(), "log-message:".into(),
None, None,
@ -651,7 +656,7 @@ pub fn dap_edit_log(cx: &mut Context) {
prompt.insert_str(&log_message, editor); prompt.insert_str(&log_message, editor);
} }
compositor.push(Box::new(prompt)); compositor.push(Box::new(prompt));
}); }));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -701,7 +706,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
.and_then(|source| source.path.clone()) .and_then(|source| source.path.clone())
.map(|path| { .map(|path| {
( (
path, path.into(),
Some(( Some((
frame.line.saturating_sub(1), frame.line.saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1),

@ -1,6 +1,7 @@
use futures_util::FutureExt;
use helix_lsp::{ use helix_lsp::{
block_on, block_on,
lsp::{self, DiagnosticSeverity, NumberOrString}, lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString},
util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range},
OffsetEncoding, OffsetEncoding,
}; };
@ -9,16 +10,19 @@ use tui::text::{Span, Spans};
use super::{align_view, push_jump, Align, Context, Editor, Open}; use super::{align_view, push_jump, Align, Context, Editor, Open};
use helix_core::{path, Selection}; use helix_core::{path, Selection};
use helix_view::{editor::Action, theme::Style}; use helix_view::{apply_transaction, document::Mode, editor::Action, theme::Style};
use crate::{ use crate::{
compositor::{self, Compositor}, compositor::{self, Compositor},
ui::{ ui::{
self, lsp::SignatureHelp, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent, self, lsp::SignatureHelp, overlay::overlayed, DynamicPicker, FileLocation, FilePicker,
Popup, PromptEvent,
}, },
}; };
use std::{borrow::Cow, collections::BTreeMap, path::PathBuf, sync::Arc}; use std::{
borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc,
};
/// Gets the language server that is attached to a document, and /// Gets the language server that is attached to a document, and
/// if it's not active displays a status message. Using this macro /// if it's not active displays a status message. Using this macro
@ -43,23 +47,32 @@ impl ui::menu::Item for lsp::Location {
type Data = PathBuf; type Data = PathBuf;
fn label(&self, cwdir: &Self::Data) -> Spans { fn label(&self, cwdir: &Self::Data) -> Spans {
let file: Cow<'_, str> = (self.uri.scheme() == "file") // The preallocation here will overallocate a few characters since it will account for the
.then(|| { // URL's scheme, which is not used most of the time since that scheme will be "file://".
self.uri // Those extra chars will be used to avoid allocating when writing the line number (in the
.to_file_path() // common case where it has 5 digits or less, which should be enough for a cast majority
.map(|path| { // of usages).
// strip root prefix let mut res = String::with_capacity(self.uri.as_str().len());
path.strip_prefix(&cwdir)
.map(|path| path.to_path_buf()) if self.uri.scheme() == "file" {
.unwrap_or(path) // With the preallocation above and UTF-8 paths already, this closure will do one (1)
}) // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`.
.map(|path| Cow::from(path.to_string_lossy().into_owned())) let mut write_path_to_res = || -> Option<()> {
.ok() let path = self.uri.to_file_path().ok()?;
}) res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy());
.flatten() Some(())
.unwrap_or_else(|| self.uri.as_str().into()); };
let line = self.range.start.line; write_path_to_res();
format!("{}:{}", file, line).into() } else {
// Never allocates since we declared the string with this capacity already.
res.push_str(self.uri.as_str());
}
// Most commonly, this will not allocate, especially on Unix systems where the root prefix
// is a simple `/` and not `C:\` (with whatever drive letter)
write!(&mut res, ":{}", self.range.start.line)
.expect("Will only failed if allocating fail");
res.into()
} }
} }
@ -73,10 +86,8 @@ impl ui::menu::Item for lsp::SymbolInformation {
} else { } else {
match self.location.uri.to_file_path() { match self.location.uri.to_file_path() {
Ok(path) => { Ok(path) => {
let relative_path = helix_core::path::get_relative_path(path.as_path()) let get_relative_path = path::get_relative_path(path.as_path());
.to_string_lossy() format!("{} ({})", &self.name, get_relative_path.to_string_lossy()).into()
.into_owned();
format!("{} ({})", &self.name, relative_path).into()
} }
Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(), Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(),
} }
@ -115,24 +126,21 @@ impl ui::menu::Item for PickerDiagnostic {
// remove background as it is distracting in the picker list // remove background as it is distracting in the picker list
style.bg = None; style.bg = None;
let code = self let code: Cow<'_, str> = self
.diag .diag
.code .code
.as_ref() .as_ref()
.map(|c| match c { .map(|c| match c {
NumberOrString::Number(n) => n.to_string(), NumberOrString::Number(n) => n.to_string().into(),
NumberOrString::String(s) => s.to_string(), NumberOrString::String(s) => s.as_str().into(),
}) })
.map(|code| format!(" ({})", code))
.unwrap_or_default(); .unwrap_or_default();
let path = match format { let path = match format {
DiagnosticsFormat::HideSourcePath => String::new(), DiagnosticsFormat::HideSourcePath => String::new(),
DiagnosticsFormat::ShowSourcePath => { DiagnosticsFormat::ShowSourcePath => {
let path = path::get_truncated_path(self.url.path()) let path = path::get_truncated_path(self.url.path());
.to_string_lossy() format!("{}: ", path.to_string_lossy())
.into_owned();
format!("{}: ", path)
} }
}; };
@ -150,7 +158,7 @@ fn location_to_file_location(location: &lsp::Location) -> FileLocation {
location.range.start.line as usize, location.range.start.line as usize,
location.range.end.line as usize, location.range.end.line as usize,
)); ));
(path, line) (path.into(), line)
} }
// TODO: share with symbol picker(symbol.location) // TODO: share with symbol picker(symbol.location)
@ -211,7 +219,6 @@ fn sym_picker(
Ok(path) => path, Ok(path) => path,
Err(_) => { Err(_) => {
let err = format!("unable to convert URI to filepath: {}", uri); let err = format!("unable to convert URI to filepath: {}", uri);
log::error!("{}", err);
cx.editor.set_error(err); cx.editor.set_error(err);
return; return;
} }
@ -328,7 +335,14 @@ pub fn symbol_picker(cx: &mut Context) {
let current_url = doc.url(); let current_url = doc.url();
let offset_encoding = language_server.offset_encoding(); 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( cx.callback(
future, future,
@ -360,15 +374,55 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
let current_url = doc.url(); let current_url = doc.url();
let language_server = language_server!(cx.editor, doc); let language_server = language_server!(cx.editor, doc);
let offset_encoding = language_server.offset_encoding(); 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( cx.callback(
future, future,
move |_editor, compositor, response: Option<Vec<lsp::SymbolInformation>>| { move |_editor, compositor, response: Option<Vec<lsp::SymbolInformation>>| {
if let Some(symbols) = response { let symbols = response.unwrap_or_default();
let picker = sym_picker(symbols, current_url, offset_encoding); let picker = sym_picker(symbols, current_url, offset_encoding);
compositor.push(Box::new(overlayed(picker))) 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<Vec<lsp::SymbolInformation>> =
serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
};
future.boxed()
};
let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols));
compositor.push(Box::new(overlayed(dyn_picker)))
}, },
) )
} }
@ -421,6 +475,63 @@ impl ui::menu::Item for lsp::CodeActionOrCommand {
} }
} }
/// Determines the category of the `CodeAction` using the `CodeAction::kind` field.
/// Returns a number that represent these categories.
/// Categories with a lower number should be displayed first.
///
///
/// While the `kind` field is defined as open ended in the LSP spec (any value may be used)
/// in practice a closed set of common values (mostly suggested in the LSP spec) are used.
/// VSCode displays each of these categories seperatly (seperated by a heading in the codeactions picker)
/// to make them easier to navigate. Helix does not display these headings to the user.
/// However it does sort code actions by their categories to achieve the same order as the VScode picker,
/// just without the headings.
///
/// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>)
fn action_category(action: &CodeActionOrCommand) -> u32 {
if let CodeActionOrCommand::CodeAction(CodeAction {
kind: Some(kind), ..
}) = action
{
let mut components = kind.as_str().split('.');
match components.next() {
Some("quickfix") => 0,
Some("refactor") => match components.next() {
Some("extract") => 1,
Some("inline") => 2,
Some("rewrite") => 3,
Some("move") => 4,
Some("surround") => 5,
_ => 7,
},
Some("source") => 6,
_ => 7,
}
} else {
7
}
}
fn action_prefered(action: &CodeActionOrCommand) -> bool {
matches!(
action,
CodeActionOrCommand::CodeAction(CodeAction {
is_preferred: Some(true),
..
})
)
}
fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool {
matches!(
action,
CodeActionOrCommand::CodeAction(CodeAction {
diagnostics: Some(diagnostics),
..
}) if !diagnostics.is_empty()
)
}
pub fn code_action(cx: &mut Context) { pub fn code_action(cx: &mut Context) {
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
@ -431,7 +542,7 @@ pub fn code_action(cx: &mut Context) {
let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); let 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(), doc.identifier(),
range, range,
// Filter and convert overlapping diagnostics // Filter and convert overlapping diagnostics
@ -447,20 +558,72 @@ pub fn code_action(cx: &mut Context) {
.collect(), .collect(),
only: None, only: None,
}, },
); ) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support code actions");
return;
}
};
cx.callback( cx.callback(
future, future,
move |editor, compositor, response: Option<lsp::CodeActionResponse>| { move |editor, compositor, response: Option<lsp::CodeActionResponse>| {
let actions = match response { let mut actions = match response {
Some(a) => a, Some(a) => a,
None => return, None => return,
}; };
// remove disabled code actions
actions.retain(|action| {
matches!(
action,
CodeActionOrCommand::Command(_)
| CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. })
)
});
if actions.is_empty() { if actions.is_empty() {
editor.set_status("No code actions available"); editor.set_status("No code actions available");
return; return;
} }
// Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec.
// Many details are modeled after vscode because langauge servers are usually tested against it.
// VScode sorts the codeaction two times:
//
// First the codeactions that fix some diagnostics are moved to the front.
// If both codeactions fix some diagnostics (or both fix none) the codeaction
// that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate
// submenus that only contain a certain category (see `action_category`) of actions.
//
// Below this done in in a single sorting step
actions.sort_by(|action1, action2| {
// sort actions by category
let order = action_category(action1).cmp(&action_category(action2));
if order != Ordering::Equal {
return order;
}
// within the categories sort by relevancy.
// Modeled after the `codeActionsComparator` function in vscode:
// https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts
// if one code action fixes a diagnostic but the other one doesn't show it first
let order = action_fixes_diagnostics(action1)
.cmp(&action_fixes_diagnostics(action2))
.reverse();
if order != Ordering::Equal {
return order;
}
// if one of the codeactions is marked as prefered show it first
// otherwise keep the original LSP sorting
action_prefered(action1)
.cmp(&action_prefered(action2))
.reverse()
});
let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| { let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| {
if event != PromptEvent::Validate { if event != PromptEvent::Validate {
return; return;
@ -491,20 +654,35 @@ pub fn code_action(cx: &mut Context) {
}); });
picker.move_down(); // pre-select the first item 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); compositor.replace_or_push("code-action", popup);
}, },
) )
} }
impl ui::menu::Item for lsp::Command {
type Data = ();
fn label(&self, _data: &Self::Data) -> Spans {
self.title.as_str().into()
}
}
pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) {
let doc = doc!(editor); let doc = doc!(editor);
let language_server = language_server!(editor, doc); let language_server = language_server!(editor, doc);
// the command is executed on the server and communicated back // the command is executed on the server and communicated back
// to the client asynchronously using workspace edits // 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 { tokio::spawn(async move {
let res = command_future.await; let res = future.await;
if let Err(e) = res { if let Err(e) = res {
log::error!("execute LSP command: {}", e); log::error!("execute LSP command: {}", e);
@ -527,7 +705,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> {
// Create directory if it does not exist // Create directory if it does not exist
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
if !dir.is_dir() { if !dir.is_dir() {
fs::create_dir_all(&dir)?; fs::create_dir_all(dir)?;
} }
} }
@ -563,7 +741,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> {
if ignore_if_exists && to.exists() { if ignore_if_exists && to.exists() {
Ok(()) Ok(())
} else { } else {
fs::rename(&from, &to) fs::rename(from, &to)
} }
} }
} }
@ -596,9 +774,7 @@ pub fn apply_workspace_edit(
} }
}; };
let doc = editor let doc = doc_mut!(editor, &doc_id);
.document_mut(doc_id)
.expect("Document for document_changes not found");
// Need to determine a view for apply/append_changes_to_history // Need to determine a view for apply/append_changes_to_history
let selections = doc.selections(); let selections = doc.selections();
@ -619,8 +795,9 @@ pub fn apply_workspace_edit(
text_edits, text_edits,
offset_encoding, offset_encoding,
); );
doc.apply(&transaction, view_id); let view = view_mut!(editor, view_id);
doc.append_changes_to_history(view_id); apply_transaction(&transaction, doc, view);
doc.append_changes_to_history(view);
}; };
if let Some(ref changes) = workspace_edit.changes { if let Some(ref changes) = workspace_edit.changes {
@ -740,7 +917,14 @@ pub fn goto_definition(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let 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( cx.callback(
future, future,
@ -758,7 +942,14 @@ pub fn goto_type_definition(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); 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( cx.callback(
future, future,
@ -776,7 +967,14 @@ pub fn goto_implementation(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); 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( cx.callback(
future, future,
@ -794,7 +992,14 @@ pub fn goto_reference(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); 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( cx.callback(
future, future,
@ -805,7 +1010,7 @@ pub fn goto_reference(cx: &mut Context) {
); );
} }
#[derive(PartialEq)] #[derive(PartialEq, Eq)]
pub enum SignatureHelpInvoked { pub enum SignatureHelpInvoked {
Manual, Manual,
Automatic, Automatic,
@ -837,7 +1042,13 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) { let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) {
Some(f) => f, Some(f) => f,
None => return, None => {
if was_manually_invoked {
cx.editor
.set_error("Language server does not support signature-help");
}
return;
}
}; };
cx.callback( cx.callback(
@ -852,6 +1063,14 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
return; return;
} }
// If the signature help invocation is automatic, don't show it outside of Insert Mode:
// it very probably means the server was a little slow to respond and the user has
// already moved on to something else, making a signature help popup will just be an
// annoyance, see https://github.com/helix-editor/helix/issues/3112
if !was_manually_invoked && editor.mode != Mode::Insert {
return;
}
let response = match response { let response = match response {
// According to the spec the response should be None if there // According to the spec the response should be None if there
// are no signatures, but some servers don't follow this. // are no signatures, but some servers don't follow this.
@ -930,7 +1149,14 @@ pub fn hover(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let 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( cx.callback(
future, future,
@ -1000,8 +1226,16 @@ pub fn rename_symbol(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); let future =
match block_on(task) { 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), Ok(edits) => apply_workspace_edit(cx.editor, offset_encoding, &edits),
Err(err) => cx.editor.set_error(err.to_string()), Err(err) => cx.editor.set_error(err.to_string()),
} }
@ -1016,7 +1250,15 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let 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( cx.callback(
future, future,

File diff suppressed because it is too large Load Diff

@ -4,8 +4,6 @@
use helix_core::Position; use helix_core::Position;
use helix_view::graphics::{CursorKind, Rect}; use helix_view::graphics::{CursorKind, Rect};
#[cfg(feature = "integration")]
use tui::backend::TestBackend;
use tui::buffer::Buffer as Surface; use tui::buffer::Buffer as Surface;
pub type Callback = Box<dyn FnOnce(&mut Compositor, &mut Context)>; pub type Callback = Box<dyn FnOnce(&mut Compositor, &mut Context)>;
@ -27,6 +25,16 @@ pub struct Context<'a> {
pub jobs: &'a mut Jobs, pub jobs: &'a mut Jobs,
} }
impl<'a> Context<'a> {
/// Waits on all pending jobs, and then tries to flush all pending write
/// operations for all documents.
pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> {
tokio::task::block_in_place(|| helix_lsp::block_on(self.jobs.finish(self.editor, None)))?;
tokio::task::block_in_place(|| helix_lsp::block_on(self.editor.flush_writes()))?;
Ok(())
}
}
pub trait Component: Any + AnyComponent { pub trait Component: Any + AnyComponent {
/// Process input events, return true if handled. /// Process input events, return true if handled.
fn handle_event(&mut self, _event: &Event, _ctx: &mut Context) -> EventResult { fn handle_event(&mut self, _event: &Event, _ctx: &mut Context) -> EventResult {
@ -65,67 +73,28 @@ pub trait Component: Any + AnyComponent {
} }
} }
use anyhow::Context as AnyhowContext;
use tui::backend::Backend;
#[cfg(not(feature = "integration"))]
use tui::backend::CrosstermBackend;
#[cfg(not(feature = "integration"))]
use std::io::stdout;
#[cfg(not(feature = "integration"))]
type Terminal = tui::terminal::Terminal<CrosstermBackend<std::io::Stdout>>;
#[cfg(feature = "integration")]
type Terminal = tui::terminal::Terminal<TestBackend>;
pub struct Compositor { pub struct Compositor {
layers: Vec<Box<dyn Component>>, layers: Vec<Box<dyn Component>>,
terminal: Terminal, area: Rect,
pub(crate) last_picker: Option<Box<dyn Component>>, pub(crate) last_picker: Option<Box<dyn Component>>,
} }
impl Compositor { impl Compositor {
pub fn new() -> anyhow::Result<Self> { pub fn new(area: Rect) -> Self {
#[cfg(not(feature = "integration"))] Self {
let backend = CrosstermBackend::new(stdout());
#[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150);
let terminal = Terminal::new(backend).context("build terminal")?;
Ok(Self {
layers: Vec::new(), layers: Vec::new(),
terminal, area,
last_picker: None, last_picker: None,
}) }
} }
pub fn size(&self) -> Rect { pub fn size(&self) -> Rect {
self.terminal.size().expect("couldn't get terminal size") self.area
} }
pub fn resize(&mut self, width: u16, height: u16) { pub fn resize(&mut self, area: Rect) {
self.terminal self.area = area;
.resize(Rect::new(0, 0, width, height))
.expect("Unable to resize terminal")
}
pub fn save_cursor(&mut self) {
if self.terminal.cursor_kind() == CursorKind::Hidden {
self.terminal
.backend_mut()
.show_cursor(CursorKind::Block)
.ok();
}
}
pub fn load_cursor(&mut self) {
if self.terminal.cursor_kind() == CursorKind::Hidden {
self.terminal.backend_mut().hide_cursor().ok();
}
} }
pub fn push(&mut self, mut layer: Box<dyn Component>) { pub fn push(&mut self, mut layer: Box<dyn Component>) {
@ -193,25 +162,10 @@ impl Compositor {
consumed consumed
} }
pub fn render(&mut self, cx: &mut Context) { pub fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
self.terminal
.autoresize()
.expect("Unable to determine terminal size");
// TODO: need to recalculate view tree if necessary
let surface = self.terminal.current_buffer_mut();
let area = *surface.area();
for layer in &mut self.layers { for layer in &mut self.layers {
layer.render(area, surface, cx); layer.render(area, surface, cx);
} }
let (pos, kind) = self.cursor(area, cx.editor);
let pos = pos.map(|pos| (pos.col as u16, pos.row as u16));
self.terminal.draw(pos, kind).unwrap();
} }
pub fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) { pub fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {

@ -283,7 +283,7 @@ fn probe_protocol(protocol_name: &str, server_cmd: Option<String>) -> std::io::R
if let Some(cmd) = server_cmd { if let Some(cmd) = server_cmd {
let path = match which::which(&cmd) { let path = match which::which(&cmd) {
Ok(path) => path.display().to_string().green(), 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)?; writeln!(stdout, "Binary for {}: {}", protocol_name, path)?;
} }

@ -5,7 +5,11 @@ use crate::compositor::Compositor;
use futures_util::future::{BoxFuture, Future, FutureExt}; use futures_util::future::{BoxFuture, Future, FutureExt};
use futures_util::stream::{FuturesUnordered, StreamExt}; use futures_util::stream::{FuturesUnordered, StreamExt};
pub type Callback = Box<dyn FnOnce(&mut Editor, &mut Compositor) + Send>; pub enum Callback {
EditorCompositor(Box<dyn FnOnce(&mut Editor, &mut Compositor) + Send>),
Editor(Box<dyn FnOnce(&mut Editor) + Send>),
}
pub type JobFuture = BoxFuture<'static, anyhow::Result<Option<Callback>>>; pub type JobFuture = BoxFuture<'static, anyhow::Result<Option<Callback>>>;
pub struct Job { pub struct Job {
@ -68,9 +72,10 @@ impl Jobs {
) { ) {
match call { match call {
Ok(None) => {} Ok(None) => {}
Ok(Some(call)) => { Ok(Some(call)) => match call {
call(editor, compositor); Callback::EditorCompositor(call) => call(editor, compositor),
} Callback::Editor(call) => call(editor),
},
Err(e) => { Err(e) => {
editor.set_error(format!("Async job failed: {}", e)); editor.set_error(format!("Async job failed: {}", e));
} }
@ -93,13 +98,32 @@ impl Jobs {
} }
/// Blocks until all the jobs that need to be waited on are done. /// Blocks until all the jobs that need to be waited on are done.
pub async fn finish(&mut self) -> anyhow::Result<()> { pub async fn finish(
&mut self,
editor: &mut Editor,
mut compositor: Option<&mut Compositor>,
) -> anyhow::Result<()> {
log::debug!("waiting on jobs..."); log::debug!("waiting on jobs...");
let mut wait_futures = std::mem::take(&mut self.wait_futures); let mut wait_futures = std::mem::take(&mut self.wait_futures);
while let (Some(job), tail) = wait_futures.into_future().await { while let (Some(job), tail) = wait_futures.into_future().await {
match job { match job {
Ok(_) => { Ok(callback) => {
wait_futures = tail; wait_futures = tail;
if let Some(callback) = callback {
// clippy doesn't realize this is an error without the derefs
#[allow(clippy::needless_option_as_deref)]
match callback {
Callback::EditorCompositor(call) if compositor.is_some() => {
call(editor, compositor.as_deref_mut().unwrap())
}
Callback::Editor(call) => call(editor),
// skip callbacks for which we don't have the necessary references
_ => (),
}
}
} }
Err(e) => { Err(e) => {
self.wait_futures = tail; self.wait_futures = tail;

@ -144,14 +144,70 @@ impl DerefMut for KeyTrieNode {
} }
} }
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq)]
#[serde(untagged)]
pub enum KeyTrie { pub enum KeyTrie {
Leaf(MappableCommand), Leaf(MappableCommand),
Sequence(Vec<MappableCommand>), Sequence(Vec<MappableCommand>),
Node(KeyTrieNode), Node(KeyTrieNode),
} }
impl<'de> Deserialize<'de> for KeyTrie {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(KeyTrieVisitor)
}
}
struct KeyTrieVisitor;
impl<'de> serde::de::Visitor<'de> for KeyTrieVisitor {
type Value = KeyTrie;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a command, list of commands, or sub-keymap")
}
fn visit_str<E>(self, command: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
command
.parse::<MappableCommand>()
.map(KeyTrie::Leaf)
.map_err(E::custom)
}
fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
where
S: serde::de::SeqAccess<'de>,
{
let mut commands = Vec::new();
while let Some(command) = seq.next_element::<&str>()? {
commands.push(
command
.parse::<MappableCommand>()
.map_err(serde::de::Error::custom)?,
)
}
Ok(KeyTrie::Sequence(commands))
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let mut mapping = HashMap::new();
let mut order = Vec::new();
while let Some((key, value)) = map.next_entry::<KeyEvent, KeyTrie>()? {
mapping.insert(key, value);
order.push(key);
}
Ok(KeyTrie::Node(KeyTrieNode::new("", mapping, order)))
}
}
impl KeyTrie { impl KeyTrie {
pub fn node(&self) -> Option<&KeyTrieNode> { pub fn node(&self) -> Option<&KeyTrieNode> {
match *self { match *self {
@ -334,18 +390,18 @@ impl Keymaps {
self.state.push(key); self.state.push(key);
match trie.search(&self.state[1..]) { match trie.search(&self.state[1..]) {
Some(&KeyTrie::Node(ref map)) => { Some(KeyTrie::Node(map)) => {
if map.is_sticky { if map.is_sticky {
self.state.clear(); self.state.clear();
self.sticky = Some(map.clone()); self.sticky = Some(map.clone());
} }
KeymapResult::Pending(map.clone()) KeymapResult::Pending(map.clone())
} }
Some(&KeyTrie::Leaf(ref cmd)) => { Some(KeyTrie::Leaf(cmd)) => {
self.state.clear(); self.state.clear();
KeymapResult::Matched(cmd.clone()) KeymapResult::Matched(cmd.clone())
} }
Some(&KeyTrie::Sequence(ref cmds)) => { Some(KeyTrie::Sequence(cmds)) => {
self.state.clear(); self.state.clear();
KeymapResult::MatchedSequence(cmds.clone()) KeymapResult::MatchedSequence(cmds.clone())
} }

@ -59,9 +59,9 @@ pub fn default() -> HashMap<Mode, Keymap> {
":" => command_mode, ":" => command_mode,
"i" => insert_mode, "i" => insert_mode,
"I" => prepend_to_line, "I" => insert_at_line_start,
"a" => append_mode, "a" => append_mode,
"A" => append_to_line, "A" => insert_at_line_end,
"o" => open_below, "o" => open_below,
"O" => open_above, "O" => open_above,
@ -76,6 +76,7 @@ pub fn default() -> HashMap<Mode, Keymap> {
"s" => select_regex, "s" => select_regex,
"A-s" => split_selection_on_newline, "A-s" => split_selection_on_newline,
"A-_" => merge_consecutive_selections,
"S" => split_selection, "S" => split_selection,
";" => collapse_selection, ";" => collapse_selection,
"A-;" => flip_selections, "A-;" => flip_selections,
@ -100,22 +101,26 @@ pub fn default() -> HashMap<Mode, Keymap> {
"[" => { "Left bracket" "[" => { "Left bracket"
"d" => goto_prev_diag, "d" => goto_prev_diag,
"D" => goto_first_diag, "D" => goto_first_diag,
"g" => goto_prev_change,
"G" => goto_first_change,
"f" => goto_prev_function, "f" => goto_prev_function,
"c" => goto_prev_class, "t" => goto_prev_class,
"a" => goto_prev_parameter, "a" => goto_prev_parameter,
"o" => goto_prev_comment, "c" => goto_prev_comment,
"t" => goto_prev_test, "T" => goto_prev_test,
"p" => goto_prev_paragraph, "p" => goto_prev_paragraph,
"space" => add_newline_above, "space" => add_newline_above,
}, },
"]" => { "Right bracket" "]" => { "Right bracket"
"d" => goto_next_diag, "d" => goto_next_diag,
"D" => goto_last_diag, "D" => goto_last_diag,
"g" => goto_next_change,
"G" => goto_last_change,
"f" => goto_next_function, "f" => goto_next_function,
"c" => goto_next_class, "t" => goto_next_class,
"a" => goto_next_parameter, "a" => goto_next_parameter,
"o" => goto_next_comment, "c" => goto_next_comment,
"t" => goto_next_test, "T" => goto_next_test,
"p" => goto_next_paragraph, "p" => goto_next_paragraph,
"space" => add_newline_below, "space" => add_newline_below,
}, },
@ -144,6 +149,7 @@ pub fn default() -> HashMap<Mode, Keymap> {
"<" => unindent, "<" => unindent,
"=" => format_selections, "=" => format_selections,
"J" => join_selections, "J" => join_selections,
"A-J" => join_selections_space,
"K" => keep_selections, "K" => keep_selections,
"A-K" => remove_selections, "A-K" => remove_selections,
@ -197,7 +203,7 @@ pub fn default() -> HashMap<Mode, Keymap> {
// z family for save/restore/combine from/to sels from register // z family for save/restore/combine from/to sels from register
"tab" => jump_forward, // tab == <C-i> "C-i" | "tab" => jump_forward, // tab == <C-i>
"C-o" => jump_backward, "C-o" => jump_backward,
"C-s" => save_selection, "C-s" => save_selection,
@ -208,11 +214,11 @@ pub fn default() -> HashMap<Mode, Keymap> {
"j" => jumplist_picker, "j" => jumplist_picker,
"s" => symbol_picker, "s" => symbol_picker,
"S" => workspace_symbol_picker, "S" => workspace_symbol_picker,
"g" => diagnostics_picker, "d" => diagnostics_picker,
"G" => workspace_diagnostics_picker, "D" => workspace_diagnostics_picker,
"a" => code_action, "a" => code_action,
"'" => last_picker, "'" => last_picker,
"d" => { "Debug (experimental)" sticky=true "g" => { "Debug (experimental)" sticky=true
"l" => dap_launch, "l" => dap_launch,
"b" => dap_toggle_breakpoint, "b" => dap_toggle_breakpoint,
"c" => dap_continue, "c" => dap_continue,
@ -342,24 +348,27 @@ pub fn default() -> HashMap<Mode, Keymap> {
let insert = keymap!({ "Insert mode" let insert = keymap!({ "Insert mode"
"esc" => normal_mode, "esc" => normal_mode,
"backspace" => delete_char_backward,
"C-h" => delete_char_backward,
"del" => delete_char_forward,
"C-d" => delete_char_forward,
"ret" => insert_newline,
"C-j" => insert_newline,
"tab" => insert_tab,
"C-w" => delete_word_backward,
"A-backspace" => delete_word_backward,
"A-d" => delete_word_forward,
"A-del" => delete_word_forward,
"C-s" => commit_undo_checkpoint, "C-s" => commit_undo_checkpoint,
"C-x" => completion,
"C-r" => insert_register,
"C-k" => kill_to_line_end, "C-w" | "A-backspace" => delete_word_backward,
"A-d" | "A-del" => delete_word_forward,
"C-u" => kill_to_line_start, "C-u" => kill_to_line_start,
"C-k" => kill_to_line_end,
"C-h" | "backspace" => delete_char_backward,
"C-d" | "del" => delete_char_forward,
"C-j" | "ret" => insert_newline,
"tab" => insert_tab,
"C-x" => completion, "up" => move_line_up,
"C-r" => insert_register, "down" => move_line_down,
"left" => move_char_left,
"right" => move_char_right,
"pageup" => page_up,
"pagedown" => page_down,
"home" => goto_line_start,
"end" => goto_line_end_newline,
}); });
hashmap!( hashmap!(
Mode::Normal => Keymap::new(normal), Mode::Normal => Keymap::new(normal),

@ -1,5 +1,6 @@
use anyhow::{Context, Error, Result}; use anyhow::{Context, Error, Result};
use crossterm::event::EventStream; use crossterm::event::EventStream;
use helix_loader::VERSION_AND_GIT_HASH;
use helix_term::application::Application; use helix_term::application::Application;
use helix_term::args::Args; use helix_term::args::Args;
use helix_term::config::Config; use helix_term::config::Config;
@ -67,13 +68,14 @@ FLAGS:
-g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml
-c, --config <file> Specifies a file to use for configuration -c, --config <file> Specifies a file to use for configuration
-v Increases logging verbosity each use for up to 3 times -v Increases logging verbosity each use for up to 3 times
--log Specifies a file to use for logging
(default file: {}) (default file: {})
-V, --version Prints version information -V, --version Prints version information
--vsplit Splits all given files vertically into different windows --vsplit Splits all given files vertically into different windows
--hsplit Splits all given files horizontally into different windows --hsplit Splits all given files horizontally into different windows
", ",
env!("CARGO_PKG_NAME"), env!("CARGO_PKG_NAME"),
env!("VERSION_AND_GIT_HASH"), VERSION_AND_GIT_HASH,
env!("CARGO_PKG_AUTHORS"), env!("CARGO_PKG_AUTHORS"),
env!("CARGO_PKG_DESCRIPTION"), env!("CARGO_PKG_DESCRIPTION"),
logpath.display(), logpath.display(),
@ -88,7 +90,7 @@ FLAGS:
} }
if args.display_version { if args.display_version {
println!("helix {}", env!("VERSION_AND_GIT_HASH")); println!("helix {}", VERSION_AND_GIT_HASH);
std::process::exit(0); std::process::exit(0);
} }
@ -114,6 +116,7 @@ FLAGS:
return Ok(0); return Ok(0);
} }
let logpath = args.log_file.as_ref().cloned().unwrap_or(logpath);
setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; setup_logging(logpath, args.verbosity).context("failed to initialize logging")?;
let config_dir = helix_loader::config_dir(); let config_dir = helix_loader::config_dir();
@ -137,8 +140,18 @@ FLAGS:
Err(err) => return Err(Error::new(err)), Err(err) => return Err(Error::new(err)),
}; };
let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| {
eprintln!("Bad language config: {}", err);
eprintln!("Press <ENTER> to continue with default language config");
use std::io::Read;
// This waits for an enter press.
let _ = std::io::stdin().read(&mut []);
helix_core::config::default_syntax_loader()
});
// TODO: use the thread local executor to spawn the application task separately from the work pool // TODO: use the thread local executor to spawn the application task separately from the work pool
let mut app = Application::new(args, config).context("unable to create new application")?; let mut app = Application::new(args, config, syn_loader_conf)
.context("unable to create new application")?;
let exit_code = app.run(&mut EventStream::new()).await?; let exit_code = app.run(&mut EventStream::new()).await?;

@ -1,5 +1,5 @@
use crate::compositor::{Component, Context, Event, EventResult}; use crate::compositor::{Component, Context, Event, EventResult};
use helix_view::editor::CompleteAction; use helix_view::{apply_transaction, editor::CompleteAction, ViewId};
use tui::buffer::Buffer as Surface; use tui::buffer::Buffer as Surface;
use tui::text::Spans; use tui::text::Spans;
@ -66,7 +66,10 @@ impl menu::Item for CompletionItem {
Some(lsp::CompletionItemKind::EVENT) => "event", Some(lsp::CompletionItemKind::EVENT) => "event",
Some(lsp::CompletionItemKind::OPERATOR) => "operator", Some(lsp::CompletionItemKind::OPERATOR) => "operator",
Some(lsp::CompletionItemKind::TYPE_PARAMETER) => "type_param", Some(lsp::CompletionItemKind::TYPE_PARAMETER) => "type_param",
Some(kind) => unimplemented!("{:?}", kind), Some(kind) => {
log::error!("Received unknown completion item kind: {:?}", kind);
""
}
None => "", None => "",
}), }),
// self.detail.as_deref().unwrap_or("") // self.detail.as_deref().unwrap_or("")
@ -92,14 +95,19 @@ impl Completion {
pub fn new( pub fn new(
editor: &Editor, editor: &Editor,
items: Vec<CompletionItem>, mut items: Vec<CompletionItem>,
offset_encoding: helix_lsp::OffsetEncoding, offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize, start_offset: usize,
trigger_offset: usize, trigger_offset: usize,
) -> Self { ) -> Self {
// Sort completion items according to their preselect status (given by the LSP server)
items.sort_by_key(|item| !item.preselect.unwrap_or(false));
// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
fn item_to_transaction( fn item_to_transaction(
doc: &Document, doc: &Document,
view_id: ViewId,
item: &CompletionItem, item: &CompletionItem,
offset_encoding: helix_lsp::OffsetEncoding, offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize, start_offset: usize,
@ -109,13 +117,15 @@ impl Completion {
let edit = match edit { let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(), lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => { lsp::CompletionTextEdit::InsertAndReplace(item) => {
unimplemented!("completion: insert_and_replace {:?}", item) // TODO: support using "insert" instead of "replace" via user config
lsp::TextEdit::new(item.replace, item.new_text.clone())
} }
}; };
util::generate_transaction_from_edits( util::generate_transaction_from_completion_edit(
doc.text(), doc.text(),
vec![edit], doc.selection(view_id),
edit,
offset_encoding, // TODO: should probably transcode in Client offset_encoding, // TODO: should probably transcode in Client
) )
} else { } else {
@ -124,10 +134,23 @@ impl Completion {
// in these cases we need to check for a common prefix and remove it // 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 prefix = Cow::from(doc.text().slice(start_offset..trigger_offset));
let text = text.trim_start_matches::<&str>(&prefix); let text = text.trim_start_matches::<&str>(&prefix);
Transaction::change(
doc.text(), // TODO: this needs to be true for the numbers to work out correctly
vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(), // 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 transaction
@ -143,11 +166,11 @@ impl Completion {
let (view, doc) = current!(editor); let (view, doc) = current!(editor);
// if more text was entered, remove it // if more text was entered, remove it
doc.restore(view.id); doc.restore(view);
match event { match event {
PromptEvent::Abort => { PromptEvent::Abort => {
doc.restore(view.id); doc.restore(view);
editor.last_completion = None; editor.last_completion = None;
} }
PromptEvent::Update => { PromptEvent::Update => {
@ -156,6 +179,7 @@ impl Completion {
let transaction = item_to_transaction( let transaction = item_to_transaction(
doc, doc,
view.id,
item, item,
offset_encoding, offset_encoding,
start_offset, start_offset,
@ -164,7 +188,7 @@ impl Completion {
// initialize a savepoint // initialize a savepoint
doc.savepoint(); doc.savepoint();
doc.apply(&transaction, view.id); apply_transaction(&transaction, doc, view);
editor.last_completion = Some(CompleteAction { editor.last_completion = Some(CompleteAction {
trigger_offset, trigger_offset,
@ -177,13 +201,14 @@ impl Completion {
let transaction = item_to_transaction( let transaction = item_to_transaction(
doc, doc,
view.id,
item, item,
offset_encoding, offset_encoding,
start_offset, start_offset,
trigger_offset, trigger_offset,
); );
doc.apply(&transaction, view.id); apply_transaction(&transaction, doc, view);
editor.last_completion = Some(CompleteAction { editor.last_completion = Some(CompleteAction {
trigger_offset, trigger_offset,
@ -213,13 +238,13 @@ impl Completion {
additional_edits.clone(), additional_edits.clone(),
offset_encoding, // TODO: should probably transcode in Client offset_encoding, // TODO: should probably transcode in Client
); );
doc.apply(&transaction, view.id); apply_transaction(&transaction, doc, view);
} }
} }
} }
}; };
}); });
let popup = Popup::new(Self::ID, menu); let popup = Popup::new(Self::ID, menu).with_scrollbar(false);
let mut completion = Self { let mut completion = Self {
popup, popup,
start_offset, start_offset,
@ -237,21 +262,13 @@ impl Completion {
completion_item: lsp::CompletionItem, completion_item: lsp::CompletionItem,
) -> Option<CompletionItem> { ) -> Option<CompletionItem> {
let language_server = doc.language_server()?; 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); let response = helix_lsp::block_on(future);
match response { match response {
Ok(completion_item) => Some(completion_item), Ok(value) => serde_json::from_value(value).ok(),
Err(err) => { Err(err) => {
log::error!("execute LSP command: {}", err); log::error!("Failed to resolve completion item: {}", err);
None None
} }
} }
@ -295,6 +312,58 @@ impl Completion {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.popup.contents().is_empty() self.popup.contents().is_empty()
} }
fn replace_item(&mut self, old_item: lsp::CompletionItem, new_item: lsp::CompletionItem) {
self.popup.contents_mut().replace_option(old_item, new_item);
}
/// Asynchronously requests that the currently selection completion item is
/// resolved through LSP `completionItem/resolve`.
pub fn ensure_item_resolved(&mut self, cx: &mut commands::Context) -> bool {
// > If computing full completion items is expensive, servers can additionally provide a
// > handler for the completion item resolve request. ...
// > A typical use case is for example: the `textDocument/completion` request doesn't fill
// > in the `documentation` property for returned completion items since it is expensive
// > to compute. When the item is selected in the user interface then a
// > 'completionItem/resolve' request is sent with the selected completion item as a parameter.
// > The returned completion item should have the documentation property filled in.
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion
let current_item = match self.popup.contents().selection() {
Some(item) if item.documentation.is_none() => item.clone(),
_ => return false,
};
let language_server = match doc!(cx.editor).language_server() {
Some(language_server) => language_server,
None => return false,
};
// This method should not block the compositor so we handle the response asynchronously.
let future = match language_server.resolve_completion_item(current_item.clone()) {
Some(future) => future,
None => return false,
};
cx.callback(
future,
move |_editor, compositor, response: Option<lsp::CompletionItem>| {
let resolved_item = match response {
Some(item) => item,
None => return,
};
if let Some(completion) = &mut compositor
.find::<crate::ui::EditorView>()
.unwrap()
.completion
{
completion.replace_item(current_item, resolved_item);
}
},
);
true
}
} }
impl Component for Completion { impl Component for Completion {
@ -342,7 +411,7 @@ impl Component for Completion {
"```{}\n{}\n```\n{}", "```{}\n{}\n```\n{}",
language, language,
option.detail.as_deref().unwrap_or_default(), option.detail.as_deref().unwrap_or_default(),
contents.clone() contents
), ),
cx.editor.syn_loader.clone(), cx.editor.syn_loader.clone(),
) )
@ -352,15 +421,14 @@ impl Component for Completion {
value: contents, value: contents,
})) => { })) => {
// TODO: set language based on doc scope // TODO: set language based on doc scope
Markdown::new( if let Some(detail) = &option.detail.as_deref() {
format!( Markdown::new(
"```{}\n{}\n```\n{}", format!("```{}\n{}\n```\n{}", language, detail, contents),
language, cx.editor.syn_loader.clone(),
option.detail.as_deref().unwrap_or_default(), )
contents.clone() } else {
), Markdown::new(contents.to_string(), cx.editor.syn_loader.clone())
cx.editor.syn_loader.clone(), }
)
} }
None if option.detail.is_some() => { None if option.detail.is_some() => {
// TODO: copied from above // TODO: copied from above

@ -1,7 +1,8 @@
use crate::{ use crate::{
commands, commands,
compositor::{Component, Context, Event, EventResult}, compositor::{Component, Context, Event, EventResult},
job, key, job::{self, Callback},
key,
keymap::{KeymapResult, Keymaps}, keymap::{KeymapResult, Keymaps},
ui::{Completion, ProgressSpinners}, ui::{Completion, ProgressSpinners},
}; };
@ -13,9 +14,10 @@ use helix_core::{
movement::Direction, movement::Direction,
syntax::{self, HighlightEvent}, syntax::{self, HighlightEvent},
unicode::width::UnicodeWidthStr, unicode::width::UnicodeWidthStr,
LineEnding, Position, Range, Selection, Transaction, visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction,
}; };
use helix_view::{ use helix_view::{
apply_transaction,
document::{Mode, SCRATCH_BUFFER_NAME}, document::{Mode, SCRATCH_BUFFER_NAME},
editor::{CompleteAction, CursorShapeConfig}, editor::{CompleteAction, CursorShapeConfig},
graphics::{Color, CursorKind, Modifier, Rect, Style}, graphics::{Color, CursorKind, Modifier, Rect, Style},
@ -23,7 +25,7 @@ use helix_view::{
keyboard::{KeyCode, KeyModifiers}, keyboard::{KeyCode, KeyModifiers},
Document, Editor, Theme, View, Document, Editor, Theme, View,
}; };
use std::{borrow::Cow, path::PathBuf}; use std::{borrow::Cow, cmp::min, num::NonZeroUsize, path::PathBuf};
use tui::buffer::Buffer as Surface; use tui::buffer::Buffer as Surface;
@ -33,6 +35,7 @@ use super::statusline;
pub struct EditorView { pub struct EditorView {
pub keymaps: Keymaps, pub keymaps: Keymaps,
on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>, on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>,
pseudo_pending: Vec<KeyEvent>,
last_insert: (commands::MappableCommand, Vec<InsertEvent>), last_insert: (commands::MappableCommand, Vec<InsertEvent>),
pub(crate) completion: Option<Completion>, pub(crate) completion: Option<Completion>,
spinners: ProgressSpinners, spinners: ProgressSpinners,
@ -56,6 +59,7 @@ impl EditorView {
Self { Self {
keymaps, keymaps,
on_next_key: None, on_next_key: None,
pseudo_pending: Vec::new(),
last_insert: (commands::MappableCommand::normal_mode, Vec::new()), last_insert: (commands::MappableCommand::normal_mode, Vec::new()),
completion: None, completion: None,
spinners: ProgressSpinners::default(), spinners: ProgressSpinners::default(),
@ -75,9 +79,10 @@ impl EditorView {
surface: &mut Surface, surface: &mut Surface,
is_focused: bool, is_focused: bool,
) { ) {
let inner = view.inner_area(); let inner = view.inner_area(doc);
let area = view.area; let area = view.area;
let theme = &editor.theme; let theme = &editor.theme;
let config = editor.config();
// DAP: Highlight current stack frame position // DAP: Highlight current stack frame position
let stack_frame = editor.debugger.as_ref().and_then(|debugger| { let stack_frame = editor.debugger.as_ref().and_then(|debugger| {
@ -113,12 +118,22 @@ impl EditorView {
} }
} }
if is_focused && editor.config().cursorline { if is_focused && config.cursorline {
Self::highlight_cursorline(doc, view, surface, theme); Self::highlight_cursorline(doc, view, surface, theme);
} }
if is_focused && config.cursorcolumn {
Self::highlight_cursorcolumn(doc, view, surface, theme);
}
let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); let mut highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme);
let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); for diagnostic in Self::doc_diagnostics_highlights(doc, theme) {
// Most of the `diagnostic` Vecs are empty most of the time. Skipping
// a merge for any empty Vec saves a significant amount of work.
if diagnostic.is_empty() {
continue;
}
highlights = Box::new(syntax::merge(highlights, diagnostic));
}
let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused { let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused {
Box::new(syntax::merge( Box::new(syntax::merge(
highlights, highlights,
@ -127,22 +142,14 @@ impl EditorView {
doc, doc,
view, view,
theme, theme,
&editor.config().cursor_shape, &config.cursor_shape,
), ),
)) ))
} else { } else {
Box::new(highlights) Box::new(highlights)
}; };
Self::render_text_highlights( Self::render_text_highlights(doc, view.offset, inner, surface, theme, highlights, &config);
doc,
view.offset,
inner,
surface,
theme,
highlights,
&editor.config(),
);
Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused); Self::render_gutter(editor, doc, view, view.area, surface, theme, is_focused);
Self::render_rulers(editor, doc, view, inner, surface, theme); Self::render_rulers(editor, doc, view, inner, surface, theme);
@ -162,7 +169,7 @@ impl EditorView {
} }
} }
self.render_diagnostics(doc, view, inner, surface, theme); Self::render_diagnostics(doc, view, inner, surface, theme);
let statusline_area = view let statusline_area = view
.area .area
@ -213,16 +220,16 @@ impl EditorView {
_theme: &Theme, _theme: &Theme,
) -> Box<dyn Iterator<Item = HighlightEvent> + 'doc> { ) -> Box<dyn Iterator<Item = HighlightEvent> + 'doc> {
let text = doc.text().slice(..); let text = doc.text().slice(..);
let last_line = std::cmp::min(
// Saturating subs to make it inclusive zero indexing.
(offset.row + height as usize).saturating_sub(1),
doc.text().len_lines().saturating_sub(1),
);
let range = { let range = {
// calculate viewport byte ranges // Calculate viewport byte ranges:
let start = text.line_to_byte(offset.row); // Saturating subs to make it inclusive zero indexing.
let end = text.line_to_byte(last_line + 1); let last_line = doc.text().len_lines().saturating_sub(1);
let last_visible_line = (offset.row + height as usize)
.saturating_sub(1)
.min(last_line);
let start = text.line_to_byte(offset.row.min(last_line));
let end = text.line_to_byte(last_visible_line + 1);
start..end start..end
}; };
@ -262,7 +269,7 @@ impl EditorView {
pub fn doc_diagnostics_highlights( pub fn doc_diagnostics_highlights(
doc: &Document, doc: &Document,
theme: &Theme, theme: &Theme,
) -> Vec<(usize, std::ops::Range<usize>)> { ) -> [Vec<(usize, std::ops::Range<usize>)>; 5] {
use helix_core::diagnostic::Severity; use helix_core::diagnostic::Severity;
let get_scope_of = |scope| { let get_scope_of = |scope| {
theme theme
@ -283,22 +290,38 @@ impl EditorView {
let error = get_scope_of("diagnostic.error"); let error = get_scope_of("diagnostic.error");
let r#default = get_scope_of("diagnostic"); // this is a bit redundant but should be fine let r#default = get_scope_of("diagnostic"); // this is a bit redundant but should be fine
doc.diagnostics() let mut default_vec: Vec<(usize, std::ops::Range<usize>)> = Vec::new();
.iter() let mut info_vec = Vec::new();
.map(|diagnostic| { let mut hint_vec = Vec::new();
let diagnostic_scope = match diagnostic.severity { let mut warning_vec = Vec::new();
Some(Severity::Info) => info, let mut error_vec = Vec::new();
Some(Severity::Hint) => hint,
Some(Severity::Warning) => warning, for diagnostic in doc.diagnostics() {
Some(Severity::Error) => error, // Separate diagnostics into different Vecs by severity.
_ => r#default, let (vec, scope) = match diagnostic.severity {
}; Some(Severity::Info) => (&mut info_vec, info),
( Some(Severity::Hint) => (&mut hint_vec, hint),
diagnostic_scope, Some(Severity::Warning) => (&mut warning_vec, warning),
diagnostic.range.start..diagnostic.range.end, Some(Severity::Error) => (&mut error_vec, error),
) _ => (&mut default_vec, r#default),
}) };
.collect()
// If any diagnostic overlaps ranges with the prior diagnostic,
// merge the two together. Otherwise push a new span.
match vec.last_mut() {
Some((_, range)) if diagnostic.range.start <= range.end => {
// This branch merges overlapping diagnostics, assuming that the current
// diagnostic starts on range.start or later. If this assertion fails,
// we will discard some part of `diagnostic`. This implies that
// `doc.diagnostics()` is not sorted by `diagnostic.range`.
debug_assert!(range.start <= diagnostic.range.start);
range.end = diagnostic.range.end.max(range.end)
}
_ => vec.push((scope, diagnostic.range.start..diagnostic.range.end)),
}
}
[default_vec, info_vec, hint_vec, warning_vec, error_vec]
} }
/// Get highlight spans for selections in a document view. /// Get highlight spans for selections in a document view.
@ -399,7 +422,7 @@ impl EditorView {
let characters = &whitespace.characters; let characters = &whitespace.characters;
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut visual_x = 0u16; let mut visual_x = 0usize;
let mut line = 0u16; let mut line = 0u16;
let tab_width = doc.tab_width(); let tab_width = doc.tab_width();
let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { let tab = if whitespace.render.tab() == WhitespaceRenderValue::All {
@ -436,17 +459,22 @@ impl EditorView {
return; return;
} }
let starting_indent = (offset.col / tab_width) as u16; let starting_indent =
// TODO: limit to a max indent level too. It doesn't cause visual artifacts but it would avoid some (offset.col / tab_width) + config.indent_guides.skip_levels as usize;
// extra loops if the code is deeply nested.
// Don't draw indent guides outside of view
for i in starting_indent..(indent_level / tab_width as u16) { let end_indent = min(
surface.set_string( indent_level,
viewport.x + (i * tab_width as u16) - offset.col as u16, // Add tab_width - 1 to round up, since the first visible
viewport.y + line, // indent might be a bit after offset.col
&indent_guide_char, offset.col + viewport.width as usize + (tab_width - 1),
indent_guide_style, ) / tab_width;
);
for i in starting_indent..end_indent {
let x = (viewport.x as usize + (i * tab_width) - offset.col) as u16;
let y = viewport.y + line;
debug_assert!(surface.in_bounds(x, y));
surface.set_string(x, y, &indent_guide_char, indent_guide_style);
} }
}; };
@ -488,14 +516,14 @@ impl EditorView {
use helix_core::graphemes::{grapheme_width, RopeGraphemes}; use helix_core::graphemes::{grapheme_width, RopeGraphemes};
for grapheme in RopeGraphemes::new(text) { for grapheme in RopeGraphemes::new(text) {
let out_of_bounds = visual_x < offset.col as u16 let out_of_bounds = offset.col > visual_x
|| visual_x >= viewport.width + offset.col as u16; || visual_x >= viewport.width as usize + offset.col;
if LineEnding::from_rope_slice(&grapheme).is_some() { if LineEnding::from_rope_slice(&grapheme).is_some() {
if !out_of_bounds { if !out_of_bounds {
// we still want to render an empty cell with the style // we still want to render an empty cell with the style
surface.set_string( surface.set_string(
viewport.x + visual_x - offset.col as u16, (viewport.x as usize + visual_x - offset.col) as u16,
viewport.y + line, viewport.y + line,
&newline, &newline,
style.patch(whitespace_style), style.patch(whitespace_style),
@ -519,7 +547,7 @@ impl EditorView {
let (display_grapheme, width) = if grapheme == "\t" { let (display_grapheme, width) = if grapheme == "\t" {
is_whitespace = true; is_whitespace = true;
// make sure we display tab as appropriate amount of spaces // make sure we display tab as appropriate amount of spaces
let visual_tab_width = tab_width - (visual_x as usize % tab_width); let visual_tab_width = tab_width - (visual_x % tab_width);
let grapheme_tab_width = let grapheme_tab_width =
helix_core::str_utils::char_to_byte_idx(&tab, visual_tab_width); helix_core::str_utils::char_to_byte_idx(&tab, visual_tab_width);
@ -538,12 +566,12 @@ impl EditorView {
(grapheme.as_ref(), width) (grapheme.as_ref(), width)
}; };
let cut_off_start = offset.col.saturating_sub(visual_x as usize); let cut_off_start = offset.col.saturating_sub(visual_x);
if !out_of_bounds { if !out_of_bounds {
// if we're offscreen just keep going until we hit a new line // if we're offscreen just keep going until we hit a new line
surface.set_string( surface.set_string(
viewport.x + visual_x - offset.col as u16, (viewport.x as usize + visual_x - offset.col) as u16,
viewport.y + line, viewport.y + line,
display_grapheme, display_grapheme,
if is_whitespace { if is_whitespace {
@ -555,7 +583,7 @@ impl EditorView {
} else if cut_off_start != 0 && cut_off_start < width { } else if cut_off_start != 0 && cut_off_start < width {
// partially on screen // partially on screen
let rect = Rect::new( let rect = Rect::new(
viewport.x as u16, viewport.x,
viewport.y + line, viewport.y + line,
(width - cut_off_start) as u16, (width - cut_off_start) as u16,
1, 1,
@ -576,7 +604,7 @@ impl EditorView {
last_line_indent_level = visual_x; last_line_indent_level = visual_x;
} }
visual_x = visual_x.saturating_add(width as u16); visual_x = visual_x.saturating_add(width);
} }
} }
} }
@ -696,26 +724,34 @@ impl EditorView {
let mut offset = 0; let mut offset = 0;
let gutter_style = theme.get("ui.gutter"); 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 // avoid lots of small allocations by reusing a text buffer for each line
let mut text = String::with_capacity(8); let mut text = String::with_capacity(8);
for (constructor, width) in view.gutters() { for gutter_type in view.gutters() {
let gutter = constructor(editor, doc, view, theme, is_focused, *width); let mut gutter = gutter_type.style(editor, doc, view, theme, is_focused);
text.reserve(*width); // ensure there's enough space for the gutter 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() { for (i, line) in (view.offset.row..(last_line + 1)).enumerate() {
let selected = cursors.contains(&line); let selected = cursors.contains(&line);
let x = viewport.x + offset; let x = viewport.x + offset;
let y = viewport.y + i as u16; let y = viewport.y + i as u16;
let gutter_style = if selected {
gutter_selected_style
} else {
gutter_style
};
if let Some(style) = gutter(line, selected, &mut text) { if let Some(style) = gutter(line, selected, &mut text) {
surface.set_stringn(x, y, &text, *width, gutter_style.patch(style)); surface.set_stringn(x, y, &text, width, gutter_style.patch(style));
} else { } else {
surface.set_style( surface.set_style(
Rect { Rect {
x, x,
y, y,
width: *width as u16, width: width as u16,
height: 1, height: 1,
}, },
gutter_style, gutter_style,
@ -724,12 +760,11 @@ impl EditorView {
text.clear(); text.clear();
} }
offset += *width as u16; offset += width as u16;
} }
} }
pub fn render_diagnostics( pub fn render_diagnostics(
&self,
doc: &Document, doc: &Document,
view: &View, view: &View,
viewport: Rect, viewport: Rect,
@ -820,6 +855,53 @@ impl EditorView {
} }
} }
/// Apply the highlighting on the columns where a cursor is active
pub fn highlight_cursorcolumn(
doc: &Document,
view: &View,
surface: &mut Surface,
theme: &Theme,
) {
let text = doc.text().slice(..);
// Manual fallback behaviour:
// ui.cursorcolumn.{p/s} -> ui.cursorcolumn -> ui.cursorline.{p/s}
let primary_style = theme
.try_get_exact("ui.cursorcolumn.primary")
.or_else(|| theme.try_get_exact("ui.cursorcolumn"))
.unwrap_or_else(|| theme.get("ui.cursorline.primary"));
let secondary_style = theme
.try_get_exact("ui.cursorcolumn.secondary")
.or_else(|| theme.try_get_exact("ui.cursorcolumn"))
.unwrap_or_else(|| theme.get("ui.cursorline.secondary"));
let inner_area = view.inner_area(doc);
let offset = view.offset.col;
let selection = doc.selection(view.id);
let primary = selection.primary();
for range in selection.iter() {
let is_primary = primary == *range;
let Position { row: _, col } =
visual_coords_at_pos(text, range.cursor(text), doc.tab_width());
// if the cursor is horizontally in the view
if col >= offset && inner_area.width > (col - offset) as u16 {
let area = Rect::new(
inner_area.x + (col - offset) as u16,
view.area.y,
1,
view.area.height,
);
if is_primary {
surface.set_style(area, primary_style)
} else {
surface.set_style(area, secondary_style)
}
}
}
}
/// Handle events by looking them up in `self.keymaps`. Returns None /// Handle events by looking them up in `self.keymaps`. Returns None
/// if event was handled (a command was executed or a subkeymap was /// if event was handled (a command was executed or a subkeymap was
/// activated). Only KeymapResult::{NotFound, Cancelled} is returned /// activated). Only KeymapResult::{NotFound, Cancelled} is returned
@ -831,6 +913,7 @@ impl EditorView {
event: KeyEvent, event: KeyEvent,
) -> Option<KeymapResult> { ) -> Option<KeymapResult> {
let mut last_mode = mode; let mut last_mode = mode;
self.pseudo_pending.extend(self.keymaps.pending());
let key_result = self.keymaps.get(mode, event); let key_result = self.keymaps.get(mode, event);
cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox());
@ -855,9 +938,10 @@ impl EditorView {
// TODO: Use an on_mode_change hook to remove signature help // TODO: Use an on_mode_change hook to remove signature help
cxt.jobs.callback(async { cxt.jobs.callback(async {
let call: job::Callback = Box::new(|_editor, compositor| { let call: job::Callback =
compositor.remove(SignatureHelp::ID); Callback::EditorCompositor(Box::new(|_editor, compositor| {
}); compositor.remove(SignatureHelp::ID);
}));
Ok(call) Ok(call)
}); });
} }
@ -918,37 +1002,40 @@ impl EditorView {
} }
// special handling for repeat operator // special handling for repeat operator
(key!('.'), _) if self.keymaps.pending().is_empty() => { (key!('.'), _) if self.keymaps.pending().is_empty() => {
// first execute whatever put us into insert mode for _ in 0..cxt.editor.count.map_or(1, NonZeroUsize::into) {
self.last_insert.0.execute(cxt); // first execute whatever put us into insert mode
// then replay the inputs self.last_insert.0.execute(cxt);
for key in self.last_insert.1.clone() { // then replay the inputs
match key { for key in self.last_insert.1.clone() {
InsertEvent::Key(key) => self.insert_mode(cxt, key), match key {
InsertEvent::CompletionApply(compl) => { InsertEvent::Key(key) => self.insert_mode(cxt, key),
let (view, doc) = current!(cxt.editor); InsertEvent::CompletionApply(compl) => {
let (view, doc) = current!(cxt.editor);
doc.restore(view.id);
doc.restore(view);
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text); let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
let shift_position =
|pos: usize| -> usize { pos + cursor - compl.trigger_offset }; let shift_position =
|pos: usize| -> usize { pos + cursor - compl.trigger_offset };
let tx = Transaction::change(
doc.text(), let tx = Transaction::change(
compl.changes.iter().cloned().map(|(start, end, t)| { doc.text(),
(shift_position(start), shift_position(end), t) compl.changes.iter().cloned().map(|(start, end, t)| {
}), (shift_position(start), shift_position(end), t)
); }),
doc.apply(&tx, view.id); );
} apply_transaction(&tx, doc, view);
InsertEvent::TriggerCompletion => { }
let (_, doc) = current!(cxt.editor); InsertEvent::TriggerCompletion => {
doc.savepoint(); let (_, doc) = current!(cxt.editor);
doc.savepoint();
}
} }
} }
} }
cxt.editor.count = None;
} }
_ => { _ => {
// set the count // set the count
@ -1005,23 +1092,20 @@ impl EditorView {
editor.clear_idle_timer(); // don't retrigger editor.clear_idle_timer(); // don't retrigger
} }
pub fn handle_idle_timeout(&mut self, cx: &mut crate::compositor::Context) -> EventResult { pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
if self.completion.is_some() if let Some(completion) = &mut self.completion {
|| cx.editor.mode != Mode::Insert return if completion.ensure_item_resolved(cx) {
|| !cx.editor.config().auto_completion EventResult::Consumed(None)
{ } else {
EventResult::Ignored(None)
};
}
if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion {
return EventResult::Ignored(None); return EventResult::Ignored(None);
} }
let mut cx = commands::Context { crate::commands::insert::idle_completion(cx);
register: None,
editor: cx.editor,
jobs: cx.jobs,
count: None,
callback: None,
on_next_key_callback: None,
};
crate::commands::insert::idle_completion(&mut cx);
EventResult::Consumed(None) EventResult::Consumed(None)
} }
@ -1071,6 +1155,7 @@ impl EditorView {
} }
editor.focus(view_id); editor.focus(view_id);
editor.ensure_cursor_in_view(view_id);
return EventResult::Consumed(None); return EventResult::Consumed(None);
} }
@ -1107,7 +1192,8 @@ impl EditorView {
let primary = selection.primary_mut(); let primary = selection.primary_mut();
*primary = primary.put_cursor(doc.text().slice(..), pos, true); *primary = primary.put_cursor(doc.text().slice(..), pos, true);
doc.set_selection(view.id, selection); doc.set_selection(view.id, selection);
let view_id = view.id;
cxt.editor.ensure_cursor_in_view(view_id);
EventResult::Consumed(None) EventResult::Consumed(None)
} }
@ -1129,6 +1215,7 @@ impl EditorView {
commands::scroll(cxt, offset, direction); commands::scroll(cxt, offset, direction);
cxt.editor.tree.focus = current_view; cxt.editor.tree.focus = current_view;
cxt.editor.ensure_cursor_in_view(current_view);
EventResult::Consumed(None) EventResult::Consumed(None)
} }
@ -1235,7 +1322,7 @@ impl Component for EditorView {
// Store a history state if not in insert mode. Otherwise wait till we exit insert // 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. // to include any edits to the paste in the history state.
if mode != Mode::Insert { if mode != Mode::Insert {
doc.append_changes_to_history(view.id); doc.append_changes_to_history(view);
} }
EventResult::Consumed(None) EventResult::Consumed(None)
@ -1308,6 +1395,11 @@ impl Component for EditorView {
} }
self.on_next_key = cx.on_next_key_callback.take(); self.on_next_key = cx.on_next_key_callback.take();
match self.on_next_key {
Some(_) => self.pseudo_pending.push(key),
None => self.pseudo_pending.clear(),
}
// appease borrowck // appease borrowck
let callback = cx.callback.take(); let callback = cx.callback.take();
@ -1329,7 +1421,7 @@ impl Component for EditorView {
// Store a history state if not in insert mode. This also takes care of // Store a history state if not in insert mode. This also takes care of
// committing changes when leaving insert mode. // committing changes when leaving insert mode.
if mode != Mode::Insert { if mode != Mode::Insert {
doc.append_changes_to_history(view.id); doc.append_changes_to_history(view);
} }
} }
@ -1337,7 +1429,16 @@ impl Component for EditorView {
} }
Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), Event::Mouse(event) => self.handle_mouse_event(event, &mut cx),
Event::FocusGained | Event::FocusLost => EventResult::Ignored(None), Event::IdleTimeout => self.handle_idle_timeout(&mut cx),
Event::FocusGained => EventResult::Ignored(None),
Event::FocusLost => {
if context.editor.config().auto_save {
if let Err(e) = commands::typed::write_all_impl(context, false, false) {
context.editor.set_error(format!("{}", e));
}
}
EventResult::Consumed(None)
}
} }
} }
@ -1408,8 +1509,8 @@ impl Component for EditorView {
for key in self.keymaps.pending() { for key in self.keymaps.pending() {
disp.push_str(&key.key_sequence_format()); disp.push_str(&key.key_sequence_format());
} }
if let Some(pseudo_pending) = &cx.editor.pseudo_pending { for key in &self.pseudo_pending {
disp.push_str(pseudo_pending.as_str()) disp.push_str(&key.key_sequence_format());
} }
let style = cx.editor.theme.get("ui.text"); let style = cx.editor.theme.get("ui.text");
let macro_width = if cx.editor.macro_recording.is_some() { let macro_width = if cx.editor.macro_recording.is_some() {

@ -0,0 +1,74 @@
use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
use fuzzy_matcher::FuzzyMatcher;
#[cfg(test)]
mod test;
pub struct FuzzyQuery {
queries: Vec<String>,
}
impl FuzzyQuery {
pub fn new(query: &str) -> FuzzyQuery {
let mut saw_backslash = false;
let queries = query
.split(|c| {
saw_backslash = match c {
' ' if !saw_backslash => return true,
'\\' => true,
_ => false,
};
false
})
.filter_map(|query| {
if query.is_empty() {
None
} else {
Some(query.replace("\\ ", " "))
}
})
.collect();
FuzzyQuery { queries }
}
pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> {
// use the rank of the first query for the rank, because merging ranks is not really possible
// this behaviour matches fzf and skim
let score = matcher.fuzzy_match(item, self.queries.get(0)?)?;
if self
.queries
.iter()
.any(|query| matcher.fuzzy_match(item, query).is_none())
{
return None;
}
Some(score)
}
pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
if self.queries.len() == 1 {
return matcher.fuzzy_indices(item, &self.queries[0]);
}
// use the rank of the first query for the rank, because merging ranks is not really possible
// this behaviour matches fzf and skim
let (score, mut indicies) = matcher.fuzzy_indices(item, self.queries.get(0)?)?;
// fast path for the common case of not using a space
// during matching this branch should be free thanks to branch prediction
if self.queries.len() == 1 {
return Some((score, indicies));
}
for query in &self.queries[1..] {
let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?;
indicies.extend_from_slice(&matched_indicies);
}
// deadup and remove duplicate matches
indicies.sort_unstable();
indicies.dedup();
Some((score, indicies))
}
}

@ -0,0 +1,47 @@
use crate::ui::fuzzy_match::FuzzyQuery;
use crate::ui::fuzzy_match::Matcher;
fn run_test<'a>(query: &str, items: &'a [&'a str]) -> Vec<String> {
let query = FuzzyQuery::new(query);
let matcher = Matcher::default();
items
.iter()
.filter_map(|item| {
let (_, indicies) = query.fuzzy_indicies(item, &matcher)?;
let matched_string = indicies
.iter()
.map(|&pos| item.chars().nth(pos).unwrap())
.collect();
Some(matched_string)
})
.collect()
}
#[test]
fn match_single_value() {
let matches = run_test("foo", &["foobar", "foo", "bar"]);
assert_eq!(matches, &["foo", "foo"])
}
#[test]
fn match_multiple_values() {
let matches = run_test(
"foo bar",
&["foo bar", "foo bar", "bar foo", "bar", "foo"],
);
assert_eq!(matches, &["foobar", "foobar", "barfoo"])
}
#[test]
fn space_escape() {
let matches = run_test(r"foo\ bar", &["bar foo", "foo bar", "foobar"]);
assert_eq!(matches, &["foo bar"])
}
#[test]
fn trim() {
let matches = run_test(r" foo bar ", &["bar foo", "foo bar", "foobar"]);
assert_eq!(matches, &["barfoo", "foobar", "foobar"]);
let matches = run_test(r" foo bar\ ", &["bar foo", "foo bar", "foobar"]);
assert_eq!(matches, &["bar foo"])
}

@ -68,8 +68,9 @@ impl Component for SignatureHelp {
let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width);
let sig_text_area = area.clip_top(1).with_height(sig_text_height); let sig_text_area = area.clip_top(1).with_height(sig_text_height);
let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area);
let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false }); let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false });
sig_text_para.render(sig_text_area.inner(&margin), surface); sig_text_para.render(sig_text_area, surface);
if self.signature_doc.is_none() { if self.signature_doc.is_none() {
return; return;

@ -40,7 +40,7 @@ impl Item for PathBuf {
type Data = PathBuf; type Data = PathBuf;
fn label(&self, root_path: &Self::Data) -> Spans { fn label(&self, root_path: &Self::Data) -> Spans {
self.strip_prefix(&root_path) self.strip_prefix(root_path)
.unwrap_or(self) .unwrap_or(self)
.to_string_lossy() .to_string_lossy()
.into() .into()
@ -77,11 +77,12 @@ impl<T: Item> Menu<T> {
editor_data: <T as Item>::Data, editor_data: <T as Item>::Data,
callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static, callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static,
) -> Self { ) -> Self {
let mut menu = Self { let matches = (0..options.len()).map(|i| (i, 0)).collect();
Self {
options, options,
editor_data, editor_data,
matcher: Box::new(Matcher::default()), matcher: Box::new(Matcher::default()),
matches: Vec::new(), matches,
cursor: None, cursor: None,
widths: Vec::new(), widths: Vec::new(),
callback_fn: Box::new(callback_fn), callback_fn: Box::new(callback_fn),
@ -89,12 +90,7 @@ impl<T: Item> Menu<T> {
size: (0, 0), size: (0, 0),
viewport: (0, 0), viewport: (0, 0),
recalculate: true, recalculate: true,
}; }
// TODO: scoring on empty input should just use a fastpath
menu.score("");
menu
} }
pub fn score(&mut self, pattern: &str) { pub fn score(&mut self, pattern: &str) {
@ -105,17 +101,15 @@ impl<T: Item> Menu<T> {
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(index, option)| { .filter_map(|(index, option)| {
let text: String = option.filter_text(&self.editor_data).into(); let text = option.filter_text(&self.editor_data);
// TODO: using fuzzy_indices could give us the char idx for match highlighting // TODO: using fuzzy_indices could give us the char idx for match highlighting
self.matcher self.matcher
.fuzzy_match(&text, pattern) .fuzzy_match(&text, pattern)
.map(|score| (index, score)) .map(|score| (index, score))
}), }),
); );
// matches.sort_unstable_by_key(|(_, score)| -score); // Order of equal elements needs to be preserved as LSP preselected items come in order of high to low priority
self.matches.sort_unstable_by_key(|(index, _score)| { self.matches.sort_by_key(|(_, score)| -score);
self.options[*index].sort_text(&self.editor_data)
});
// reset cursor position // reset cursor position
self.cursor = None; self.cursor = None;
@ -213,6 +207,14 @@ impl<T: Item> Menu<T> {
}) })
} }
pub fn selection_mut(&mut self) -> Option<&mut T> {
self.cursor.and_then(|cursor| {
self.matches
.get(cursor)
.map(|(index, _score)| &mut self.options[*index])
})
}
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.matches.is_empty() self.matches.is_empty()
} }
@ -222,6 +224,17 @@ impl<T: Item> Menu<T> {
} }
} }
impl<T: Item + PartialEq> Menu<T> {
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; use super::PromptEvent as MenuEvent;
impl<T: Item + 'static> Component for Menu<T> { impl<T: Item + 'static> Component for Menu<T> {
@ -318,11 +331,6 @@ impl<T: Item + 'static> Component for Menu<T> {
(a + b - 1) / b (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.row(&self.editor_data));
let table = Table::new(rows) let table = Table::new(rows)
.style(style) .style(style)
@ -355,20 +363,24 @@ impl<T: Item + 'static> Component for Menu<T> {
let fits = len <= win_height; let fits = len <= win_height;
let scroll_style = theme.get("ui.menu.scroll"); let scroll_style = theme.get("ui.menu.scroll");
for (i, _) in (scroll..(scroll + win_height).min(len)).enumerate() { if !fits {
let cell = &mut surface[(area.x + area.width - 1, area.y + i as u16)]; 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 { let mut cell;
// Draw scroll track for i in 0..win_height {
cell.set_symbol("▐"); // right half block cell = &mut surface[(area.right() - 1, area.top() + i as u16)];
cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset));
}
let is_marked = i >= scroll_line && i < scroll_line + scroll_height; cell.set_symbol("▐"); // right half block
if !fits && is_marked { if scroll_line <= i && i < scroll_line + scroll_height {
// Draw scroll thumb // Draw scroll thumb
cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset)); 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));
}
} }
} }
} }

@ -1,5 +1,6 @@
mod completion; mod completion;
pub(crate) mod editor; pub(crate) mod editor;
mod fuzzy_match;
mod info; mod info;
pub mod lsp; pub mod lsp;
mod markdown; mod markdown;
@ -12,11 +13,13 @@ mod spinner;
mod statusline; mod statusline;
mod text; mod text;
use crate::compositor::{Component, Compositor};
use crate::job::{self, Callback};
pub use completion::Completion; pub use completion::Completion;
pub use editor::EditorView; pub use editor::EditorView;
pub use markdown::Markdown; pub use markdown::Markdown;
pub use menu::Menu; pub use menu::Menu;
pub use picker::{FileLocation, FilePicker, Picker}; pub use picker::{DynamicPicker, FileLocation, FilePicker, Picker};
pub use popup::Popup; pub use popup::Popup;
pub use prompt::{Prompt, PromptEvent}; pub use prompt::{Prompt, PromptEvent};
pub use spinner::{ProgressSpinners, Spinner}; pub use spinner::{ProgressSpinners, Spinner};
@ -24,7 +27,7 @@ pub use text::Text;
use helix_core::regex::Regex; use helix_core::regex::Regex;
use helix_core::regex::RegexBuilder; use helix_core::regex::RegexBuilder;
use helix_view::{Document, Editor, View}; use helix_view::Editor;
use std::path::PathBuf; use std::path::PathBuf;
@ -59,7 +62,7 @@ pub fn regex_prompt(
prompt: std::borrow::Cow<'static, str>, prompt: std::borrow::Cow<'static, str>,
history_register: Option<char>, history_register: Option<char>,
completion_fn: impl FnMut(&Editor, &str) -> Vec<prompt::Completion> + 'static, completion_fn: impl FnMut(&Editor, &str) -> Vec<prompt::Completion> + 'static,
fun: impl Fn(&mut View, &mut Document, Regex, PromptEvent) + 'static, fun: impl Fn(&mut Editor, Regex, PromptEvent) + 'static,
) { ) {
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
let doc_id = view.doc; let doc_id = view.doc;
@ -106,11 +109,42 @@ pub fn regex_prompt(
view.jumps.push((doc_id, snapshot.clone())); view.jumps.push((doc_id, snapshot.clone()));
} }
fun(view, doc, regex, event); fun(cx.editor, regex, event);
let (view, doc) = current!(cx.editor);
view.ensure_cursor_in_view(doc, config.scrolloff); view.ensure_cursor_in_view(doc, config.scrolloff);
} }
Err(_err) => (), // TODO: mark command line as error Err(err) => {
let (view, doc) = current!(cx.editor);
doc.set_selection(view.id, snapshot.clone());
view.offset = offset_snapshot;
if event == PromptEvent::Validate {
let callback = async move {
let call: job::Callback = Callback::EditorCompositor(Box::new(
move |_editor: &mut Editor, compositor: &mut Compositor| {
let contents = Text::new(format!("{}", err));
let size = compositor.size();
let mut popup = Popup::new("invalid-regex", contents)
.position(Some(helix_core::Position::new(
size.height as usize - 2, // 2 = statusline + commandline
0,
)))
.auto_close(true);
popup.required_size((size.width, size.height));
compositor.replace_or_push("invalid-regex", popup);
},
));
Ok(call)
};
cx.jobs.callback(callback);
} else {
// Update
// TODO: mark command line as error
}
}
} }
} }
} }
@ -173,13 +207,14 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
// Cap the number of files if we aren't in a git project, preventing // Cap the number of files if we aren't in a git project, preventing
// hangs when using the picker in your home directory // hangs when using the picker in your home directory
let files: Vec<_> = if root.join(".git").is_dir() { let mut files: Vec<PathBuf> = if root.join(".git").exists() {
files.collect() files.collect()
} else { } else {
// const MAX: usize = 8192; // const MAX: usize = 8192;
const MAX: usize = 100_000; const MAX: usize = 100_000;
files.take(MAX).collect() files.take(MAX).collect()
}; };
files.sort();
log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); log::debug!("file_picker init {:?}", Instant::now().duration_since(now));
@ -196,7 +231,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
cx.editor.set_error(err); cx.editor.set_error(err);
} }
}, },
|_editor, path| Some((path.clone(), None)), |_editor, path| Some((path.clone().into(), None)),
) )
} }
@ -220,8 +255,8 @@ pub mod completers {
pub fn buffer(editor: &Editor, input: &str) -> Vec<Completion> { pub fn buffer(editor: &Editor, input: &str) -> Vec<Completion> {
let mut names: Vec<_> = editor let mut names: Vec<_> = editor
.documents .documents
.iter() .values()
.map(|(_id, doc)| { .map(|doc| {
let name = doc let name = doc
.relative_path() .relative_path()
.map(|p| p.display().to_string()) .map(|p| p.display().to_string())
@ -356,6 +391,45 @@ pub mod completers {
.collect() .collect()
} }
pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec<Completion> {
let matcher = Matcher::default();
let (_, doc) = current_ref!(editor);
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => {
return vec![];
}
};
let options = match &language_server.capabilities().execute_command_provider {
Some(options) => options,
None => {
return vec![];
}
};
let mut matches: Vec<_> = options
.commands
.iter()
.filter_map(|command| {
matcher
.fuzzy_match(command, input)
.map(|score| (command, score))
})
.collect();
matches.sort_unstable_by(|(command1, score1), (command2, score2)| {
(Reverse(*score1), command1).cmp(&(Reverse(*score2), command2))
});
matches
.into_iter()
.map(|(command, _score)| ((0..), command.clone().into()))
.collect()
}
pub fn directory(editor: &Editor, input: &str) -> Vec<Completion> { pub fn directory(editor: &Editor, input: &str) -> Vec<Completion> {
filename_impl(editor, input, |entry| { filename_impl(editor, input, |entry| {
let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir());

@ -69,4 +69,8 @@ impl<T: Component + 'static> Component for Overlay<T> {
let dimensions = (self.calc_child_size)(area); let dimensions = (self.calc_child_size)(area);
self.content.cursor(dimensions, ctx) self.content.cursor(dimensions, ctx)
} }
fn id(&self) -> Option<&'static str> {
self.content.id()
}
} }

@ -1,41 +1,67 @@
use crate::{ use crate::{
compositor::{Component, Compositor, Context, Event, EventResult}, compositor::{Component, Compositor, Context, Event, EventResult},
ctrl, key, shift, ctrl, key, shift,
ui::{self, EditorView}, ui::{self, fuzzy_match::FuzzyQuery, EditorView},
}; };
use futures_util::future::BoxFuture;
use tui::{ use tui::{
buffer::Buffer as Surface, buffer::Buffer as Surface,
widgets::{Block, BorderType, Borders}, widgets::{Block, BorderType, Borders},
}; };
use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
use fuzzy_matcher::FuzzyMatcher;
use tui::widgets::Widget; use tui::widgets::Widget;
use std::time::Instant;
use std::{ use std::{
cmp::Reverse, cmp::{self, Ordering},
collections::HashMap, time::Instant,
io::Read,
path::{Path, PathBuf},
}; };
use std::{collections::HashMap, io::Read, path::PathBuf};
use crate::ui::{Prompt, PromptEvent}; use crate::ui::{Prompt, PromptEvent};
use helix_core::{movement::Direction, Position}; use helix_core::{movement::Direction, Position};
use helix_view::{ use helix_view::{
editor::Action, editor::Action,
graphics::{CursorKind, Margin, Modifier, Rect}, graphics::{CursorKind, Margin, Modifier, Rect},
Document, Editor, Document, DocumentId, Editor,
}; };
use super::menu::Item; use super::{menu::Item, overlay::Overlay};
pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72;
/// Biggest file size to preview in bytes /// Biggest file size to preview in bytes
pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; 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<Self> {
use PathOrId::*;
Ok(match self {
Path(path) => Path(helix_core::path::get_canonicalized_path(&path)?),
Id(id) => Id(id),
})
}
}
impl From<PathBuf> for PathOrId {
fn from(v: PathBuf) -> Self {
Self::Path(v)
}
}
impl From<DocumentId> for PathOrId {
fn from(v: DocumentId) -> Self {
Self::Id(v)
}
}
/// File path and range of lines (used to align and highlight lines) /// 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<T: Item> { pub struct FilePicker<T: Item> {
picker: Picker<T>, picker: Picker<T>,
@ -114,52 +140,82 @@ impl<T: Item> FilePicker<T> {
self.picker self.picker
.selection() .selection()
.and_then(|current| (self.file_fn)(editor, current)) .and_then(|current| (self.file_fn)(editor, current))
.and_then(|(path, line)| { .and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line)))
helix_core::path::get_canonicalized_path(&path)
.ok()
.zip(Some(line))
})
} }
/// Get (cached) preview for a given path. If a document corresponding /// Get (cached) preview for a given path. If a document corresponding
/// to the path is already open in the editor, it is used instead. /// to the path is already open in the editor, it is used instead.
fn get_preview<'picker, 'editor>( fn get_preview<'picker, 'editor>(
&'picker mut self, &'picker mut self,
path: &Path, path_or_id: PathOrId,
editor: &'editor Editor, editor: &'editor Editor,
) -> Preview<'picker, 'editor> { ) -> Preview<'picker, 'editor> {
if let Some(doc) = editor.document_by_path(path) { match path_or_id {
return Preview::EditorDocument(doc); 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) { if self.preview_cache.contains_key(path) {
return Preview::Cached(&self.preview_cache[path]); return Preview::Cached(&self.preview_cache[path]);
}
let data = std::fs::File::open(path).and_then(|file| {
let metadata = file.metadata()?;
// Read up to 1kb to detect the content type
let n = file.take(1024).read_to_end(&mut self.read_buffer)?;
let content_type = content_inspector::inspect(&self.read_buffer[..n]);
self.read_buffer.clear();
Ok((metadata, content_type))
});
let preview = data
.map(
|(metadata, content_type)| match (metadata.len(), content_type) {
(_, content_inspector::ContentType::BINARY) => CachedPreview::Binary,
(size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => {
CachedPreview::LargeFile
}
_ => {
// TODO: enable syntax highlighting; blocked by async rendering
Document::open(path, None, None)
.map(|doc| CachedPreview::Document(Box::new(doc)))
.unwrap_or(CachedPreview::NotFound)
}
},
)
.unwrap_or(CachedPreview::NotFound);
self.preview_cache.insert(path.to_owned(), preview);
Preview::Cached(&self.preview_cache[path])
}
PathOrId::Id(id) => {
let doc = editor.documents.get(&id).unwrap();
Preview::EditorDocument(doc)
}
} }
}
let data = std::fs::File::open(path).and_then(|file| { fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult {
let metadata = file.metadata()?; // Try to find a document in the cache
// Read up to 1kb to detect the content type let doc = self
let n = file.take(1024).read_to_end(&mut self.read_buffer)?; .current_file(cx.editor)
let content_type = content_inspector::inspect(&self.read_buffer[..n]); .and_then(|(path, _range)| match path {
self.read_buffer.clear(); PathOrId::Id(doc_id) => Some(doc_mut!(cx.editor, &doc_id)),
Ok((metadata, content_type)) PathOrId::Path(path) => match self.preview_cache.get_mut(&path) {
}); Some(CachedPreview::Document(doc)) => Some(doc),
let preview = data _ => None,
.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); // Then attempt to highlight it if it has no language set
Preview::Cached(&self.preview_cache[path]) if let Some(doc) = doc {
if doc.language_config().is_none() {
let loader = cx.editor.syn_loader.clone();
doc.detect_language(loader);
}
}
EventResult::Consumed(None)
} }
} }
@ -205,7 +261,7 @@ impl<T: Item + 'static> Component for FilePicker<T> {
block.render(preview_area, surface); block.render(preview_area, surface);
if let Some((path, range)) = self.current_file(cx.editor) { 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() { let doc = match preview.document() {
Some(doc) => doc, Some(doc) => doc,
None => { None => {
@ -228,8 +284,14 @@ impl<T: Item + 'static> Component for FilePicker<T> {
let offset = Position::new(first_line, 0); let offset = Position::new(first_line, 0);
let highlights = let mut highlights =
EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme); EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme);
for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) {
if spans.is_empty() {
continue;
}
highlights = Box::new(helix_core::syntax::merge(highlights, spans));
}
EditorView::render_text_highlights( EditorView::render_text_highlights(
doc, doc,
offset, offset,
@ -261,6 +323,9 @@ impl<T: Item + 'static> Component for FilePicker<T> {
} }
fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult {
if let Event::IdleTimeout = event {
return self.handle_idle_timeout(ctx);
}
// TODO: keybinds for scrolling preview // TODO: keybinds for scrolling preview
self.picker.handle_event(event, ctx) self.picker.handle_event(event, ctx)
} }
@ -280,15 +345,37 @@ impl<T: Item + 'static> Component for FilePicker<T> {
} }
} }
#[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<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for PickerMatch {
fn cmp(&self, other: &Self) -> Ordering {
self.key().cmp(&other.key())
}
}
pub struct Picker<T: Item> { pub struct Picker<T: Item> {
options: Vec<T>, options: Vec<T>,
editor_data: T::Data, editor_data: T::Data,
// filter: String, // filter: String,
matcher: Box<Matcher>, matcher: Box<Matcher>,
/// (index, score) matches: Vec<PickerMatch>,
matches: Vec<(usize, i64)>,
/// Filter over original options.
filters: Vec<usize>, // could be optimized into bit but not worth it now
/// Current height of the completions box /// Current height of the completions box
completion_height: u16, completion_height: u16,
@ -323,7 +410,6 @@ impl<T: Item> Picker<T> {
editor_data, editor_data,
matcher: Box::new(Matcher::default()), matcher: Box::new(Matcher::default()),
matches: Vec::new(), matches: Vec::new(),
filters: Vec::new(),
cursor: 0, cursor: 0,
prompt, prompt,
previous_pattern: String::new(), previous_pattern: String::new(),
@ -335,13 +421,16 @@ impl<T: Item> Picker<T> {
// scoring on empty input: // scoring on empty input:
// TODO: just reuse score() // TODO: just reuse score()
picker.matches.extend( picker
picker .matches
.options .extend(picker.options.iter().enumerate().map(|(index, option)| {
.iter() let text = option.filter_text(&picker.editor_data);
.enumerate() PickerMatch {
.map(|(index, _option)| (index, 0)), index,
); score: 0,
len: text.chars().count(),
}
}));
picker picker
} }
@ -358,68 +447,71 @@ impl<T: Item> Picker<T> {
if pattern.is_empty() { if pattern.is_empty() {
// Fast path for no pattern. // Fast path for no pattern.
self.matches.clear(); self.matches.clear();
self.matches.extend( self.matches
self.options .extend(self.options.iter().enumerate().map(|(index, option)| {
.iter() let text = option.filter_text(&self.editor_data);
.enumerate() PickerMatch {
.map(|(index, _option)| (index, 0)), index,
); score: 0,
len: text.chars().count(),
}
}));
} else if pattern.starts_with(&self.previous_pattern) { } else if pattern.starts_with(&self.previous_pattern) {
// TODO: remove when retain_mut is in stable rust let query = FuzzyQuery::new(pattern);
#[allow(unused_imports, deprecated)]
use retain_mut::RetainMut;
// optimization: if the pattern is a more specific version of the previous one // optimization: if the pattern is a more specific version of the previous one
// then we can score the filtered set. // then we can score the filtered set.
#[allow(unstable_name_collisions)] self.matches.retain_mut(|pmatch| {
self.matches.retain_mut(|(index, score)| { let option = &self.options[pmatch.index];
let option = &self.options[*index];
let text = option.sort_text(&self.editor_data); let text = option.sort_text(&self.editor_data);
match self.matcher.fuzzy_match(&text, pattern) { match query.fuzzy_match(&text, &self.matcher) {
Some(s) => { Some(s) => {
// Update the score // Update the score
*score = s; pmatch.score = s;
true true
} }
None => false, None => false,
} }
}); });
self.matches self.matches.sort_unstable();
.sort_unstable_by_key(|(_, score)| Reverse(*score));
} else { } else {
self.matches.clear(); self.force_score();
self.matches.extend(
self.options
.iter()
.enumerate()
.filter_map(|(index, option)| {
// filter options first before matching
if !self.filters.is_empty() {
// TODO: this filters functionality seems inefficient,
// instead store and operate on filters if any
self.filters.binary_search(&index).ok()?;
}
let text = option.filter_text(&self.editor_data);
self.matcher
.fuzzy_match(&text, pattern)
.map(|score| (index, score))
}),
);
self.matches
.sort_unstable_by_key(|(_, score)| Reverse(*score));
} }
log::debug!("picker score {:?}", Instant::now().duration_since(now)); log::debug!("picker score {:?}", Instant::now().duration_since(now));
// reset cursor position // reset cursor position
self.cursor = 0; self.cursor = 0;
let pattern = self.prompt.line();
self.previous_pattern.clone_from(pattern); self.previous_pattern.clone_from(pattern);
} }
pub fn force_score(&mut self) {
let pattern = self.prompt.line();
let query = FuzzyQuery::new(pattern);
self.matches.clear();
self.matches.extend(
self.options
.iter()
.enumerate()
.filter_map(|(index, option)| {
let text = option.filter_text(&self.editor_data);
query
.fuzzy_match(&text, &self.matcher)
.map(|score| PickerMatch {
index,
score,
len: text.chars().count(),
})
}),
);
self.matches.sort_unstable();
}
/// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`) /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`)
pub fn move_by(&mut self, amount: usize, direction: Direction) { pub fn move_by(&mut self, amount: usize, direction: Direction) {
let len = self.matches.len(); let len = self.matches.len();
@ -462,15 +554,7 @@ impl<T: Item> Picker<T> {
pub fn selection(&self) -> Option<&T> { pub fn selection(&self) -> Option<&T> {
self.matches self.matches
.get(self.cursor) .get(self.cursor)
.map(|(index, _score)| &self.options[*index]) .map(|pmatch| &self.options[pmatch.index])
}
pub fn save_filter(&mut self, cx: &Context) {
self.filters.clear();
self.filters
.extend(self.matches.iter().map(|(index, _)| *index));
self.filters.sort_unstable(); // used for binary search later
self.prompt.clear(cx.editor);
} }
pub fn toggle_preview(&mut self) { pub fn toggle_preview(&mut self) {
@ -510,6 +594,9 @@ impl<T: Item + 'static> Component for Picker<T> {
compositor.last_picker = compositor.pop(); compositor.last_picker = compositor.pop();
}))); })));
// So that idle timeout retriggers
cx.editor.reset_idle_timer();
match key_event { match key_event {
shift!(Tab) | key!(Up) | ctrl!('p') => { shift!(Tab) | key!(Up) | ctrl!('p') => {
self.move_by(1, Direction::Backward); self.move_by(1, Direction::Backward);
@ -550,9 +637,6 @@ impl<T: Item + 'static> Component for Picker<T> {
} }
return close_fn; return close_fn;
} }
ctrl!(' ') => {
self.save_filter(cx);
}
ctrl!('t') => { ctrl!('t') => {
self.toggle_preview(); self.toggle_preview();
} }
@ -617,7 +701,7 @@ impl<T: Item + 'static> Component for Picker<T> {
.matches .matches
.iter() .iter()
.skip(offset) .skip(offset)
.map(|(index, _score)| (*index, self.options.get(*index).unwrap())); .map(|pmatch| (pmatch.index, self.options.get(pmatch.index).unwrap()));
for (i, (_index, option)) in files.take(rows as usize).enumerate() { for (i, (_index, option)) in files.take(rows as usize).enumerate() {
let is_active = i == (self.cursor - offset); let is_active = i == (self.cursor - offset);
@ -635,9 +719,8 @@ impl<T: Item + 'static> Component for Picker<T> {
} }
let spans = option.label(&self.editor_data); let spans = option.label(&self.editor_data);
let (_score, highlights) = self let (_score, highlights) = FuzzyQuery::new(self.prompt.line())
.matcher .fuzzy_indicies(&String::from(&spans), &self.matcher)
.fuzzy_indices(&String::from(&spans), self.prompt.line())
.unwrap_or_default(); .unwrap_or_default();
spans.0.into_iter().fold(inner, |pos, span| { spans.0.into_iter().fold(inner, |pos, span| {
@ -676,3 +759,78 @@ impl<T: Item + 'static> Component for Picker<T> {
self.prompt.cursor(area, editor) 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<T> =
Box<dyn Fn(String, &mut Editor) -> BoxFuture<'static, anyhow::Result<Vec<T>>>>;
/// A picker that updates its contents via a callback whenever the
/// query string changes. Useful for live grep, workspace symbols, etc.
pub struct DynamicPicker<T: ui::menu::Item + Send> {
file_picker: FilePicker<T>,
query_callback: DynQueryCallback<T>,
query: String,
}
impl<T: ui::menu::Item + Send> DynamicPicker<T> {
pub const ID: &'static str = "dynamic-picker";
pub fn new(file_picker: FilePicker<T>, query_callback: DynQueryCallback<T>) -> Self {
Self {
file_picker,
query_callback,
query: String::new(),
}
}
}
impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
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::<Overlay<DynamicPicker<T>>>(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<Position>, 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)
}
}

@ -22,6 +22,7 @@ pub struct Popup<T: Component> {
auto_close: bool, auto_close: bool,
ignore_escape_key: bool, ignore_escape_key: bool,
id: &'static str, id: &'static str,
has_scrollbar: bool,
} }
impl<T: Component> Popup<T> { impl<T: Component> Popup<T> {
@ -37,6 +38,7 @@ impl<T: Component> Popup<T> {
auto_close: false, auto_close: false,
ignore_escape_key: false, ignore_escape_key: false,
id, id,
has_scrollbar: true,
} }
} }
@ -128,6 +130,14 @@ impl<T: Component> Popup<T> {
} }
} }
/// 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 { pub fn contents(&self) -> &T {
&self.contents &self.contents
} }
@ -228,6 +238,40 @@ impl<T: Component> Component for Popup<T> {
let inner = area.inner(&self.margin); let inner = area.inner(&self.margin);
self.contents.render(inner, surface, cx); 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> { fn id(&self) -> Option<&'static str> {

@ -31,7 +31,7 @@ pub struct Prompt {
next_char_handler: Option<PromptCharHandler>, next_char_handler: Option<PromptCharHandler>,
} }
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum PromptEvent { pub enum PromptEvent {
/// The prompt input has been updated. /// The prompt input has been updated.
Update, Update,
@ -293,27 +293,28 @@ impl Prompt {
register: char, register: char,
direction: CompletionDirection, direction: CompletionDirection,
) { ) {
let register = cx.editor.registers.get_mut(register).read(); (self.callback_fn)(cx, &self.line, PromptEvent::Abort);
let values = match cx.editor.registers.read(register) {
if register.is_empty() { Some(values) if !values.is_empty() => values,
return; _ => return,
} };
let end = register.len().saturating_sub(1); let end = values.len().saturating_sub(1);
let index = match direction { let index = match direction {
CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1), CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1),
CompletionDirection::Backward => { CompletionDirection::Backward => {
self.history_pos.unwrap_or(register.len()).saturating_sub(1) self.history_pos.unwrap_or(values.len()).saturating_sub(1)
} }
} }
.min(end); .min(end);
self.line = register[index].clone(); self.line = values[index].clone();
self.history_pos = Some(index); self.history_pos = Some(index);
self.move_end(); self.move_end();
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
self.recalculate_completion(cx.editor); self.recalculate_completion(cx.editor);
} }
@ -351,6 +352,7 @@ impl Prompt {
let prompt_color = theme.get("ui.text"); let prompt_color = theme.get("ui.text");
let completion_color = theme.get("ui.menu"); let completion_color = theme.get("ui.menu");
let selected_color = theme.get("ui.menu.selected"); let selected_color = theme.get("ui.menu.selected");
let suggestion_color = theme.get("ui.text.inactive");
// completion // completion
let max_len = self let max_len = self
@ -402,7 +404,7 @@ impl Prompt {
surface.set_stringn( surface.set_stringn(
area.x + col * (1 + col_width), area.x + col * (1 + col_width),
area.y + row, area.y + row,
&completion, completion,
col_width.saturating_sub(1) as usize, col_width.saturating_sub(1) as usize,
color, color,
); );
@ -449,21 +451,29 @@ impl Prompt {
// render buffer text // render buffer text
surface.set_string(area.x, area.y + line, &self.prompt, prompt_color); surface.set_string(area.x, area.y + line, &self.prompt, prompt_color);
let input: Cow<str> = if self.line.is_empty() { let (input, is_suggestion): (Cow<str>, bool) = if self.line.is_empty() {
// latest value in the register list // latest value in the register list
self.history_register match self
.history_register
.and_then(|reg| cx.editor.registers.last(reg)) .and_then(|reg| cx.editor.registers.last(reg))
.map(|entry| entry.into()) .map(|entry| entry.into())
.unwrap_or_else(|| Cow::from("")) {
Some(value) => (value, true),
None => (Cow::from(""), false),
}
} else { } else {
self.line.as_str().into() (self.line.as_str().into(), false)
}; };
surface.set_string( surface.set_string(
area.x + self.prompt.len() as u16, area.x + self.prompt.len() as u16,
area.y + line, area.y + line,
&input, &input,
prompt_color, if is_suggestion {
suggestion_color
} else {
prompt_color
},
); );
} }
} }
@ -546,10 +556,7 @@ impl Component for Prompt {
if last_item != self.line { if last_item != self.line {
// store in history // store in history
if let Some(register) = self.history_register { if let Some(register) = self.history_register {
cx.editor cx.editor.registers.push(register, self.line.clone());
.registers
.get_mut(register)
.push(self.line.clone());
}; };
} }
@ -564,13 +571,11 @@ impl Component for Prompt {
ctrl!('p') | key!(Up) => { ctrl!('p') | key!(Up) => {
if let Some(register) = self.history_register { if let Some(register) = self.history_register {
self.change_history(cx, register, CompletionDirection::Backward); self.change_history(cx, register, CompletionDirection::Backward);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
} }
} }
ctrl!('n') | key!(Down) => { ctrl!('n') | key!(Down) => {
if let Some(register) = self.history_register { if let Some(register) = self.history_register {
self.change_history(cx, register, CompletionDirection::Forward); self.change_history(cx, register, CompletionDirection::Forward);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
} }
} }
key!(Tab) => { key!(Tab) => {

@ -1,4 +1,5 @@
use helix_core::{coords_at_pos, encoding, Position}; use helix_core::{coords_at_pos, encoding, Position};
use helix_lsp::lsp::DiagnosticSeverity;
use helix_view::{ use helix_view::{
document::{Mode, SCRATCH_BUFFER_NAME}, document::{Mode, SCRATCH_BUFFER_NAME},
graphics::Rect, graphics::Rect,
@ -68,7 +69,9 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface
// Left side of the status line. // 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 element_ids
.iter() .iter()
.map(|element_id| get_render_function(*element_id)) .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. // Right side of the status line.
let element_ids = &context.editor.config().statusline.right; let element_ids = &config.statusline.right;
element_ids element_ids
.iter() .iter()
.map(|element_id| get_render_function(*element_id)) .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. // Center of the status line.
let element_ids = &context.editor.config().statusline.center; let element_ids = &config.statusline.center;
element_ids element_ids
.iter() .iter()
.map(|element_id| get_render_function(*element_id)) .map(|element_id| get_render_function(*element_id))
@ -141,9 +144,14 @@ where
helix_view::editor::StatusLineElement::FileLineEnding => render_file_line_ending, helix_view::editor::StatusLineElement::FileLineEnding => render_file_line_ending,
helix_view::editor::StatusLineElement::FileType => render_file_type, helix_view::editor::StatusLineElement::FileType => render_file_type,
helix_view::editor::StatusLineElement::Diagnostics => render_diagnostics, 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::Selections => render_selections,
helix_view::editor::StatusLineElement::PrimarySelectionLength => {
render_primary_selection_length
}
helix_view::editor::StatusLineElement::Position => render_position, helix_view::editor::StatusLineElement::Position => render_position,
helix_view::editor::StatusLineElement::PositionPercentage => render_position_percentage, helix_view::editor::StatusLineElement::PositionPercentage => render_position_percentage,
helix_view::editor::StatusLineElement::TotalLineNumbers => render_total_line_numbers,
helix_view::editor::StatusLineElement::Separator => render_separator, helix_view::editor::StatusLineElement::Separator => render_separator,
helix_view::editor::StatusLineElement::Spacer => render_spacer, helix_view::editor::StatusLineElement::Spacer => render_spacer,
} }
@ -154,23 +162,24 @@ where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy, F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{ {
let visible = context.focused; let visible = context.focused;
let config = context.editor.config();
let modenames = &config.statusline.mode;
write( write(
context, context,
format!( format!(
" {} ", " {} ",
if visible { if visible {
match context.editor.mode() { match context.editor.mode() {
Mode::Insert => "INS", Mode::Insert => &modenames.insert,
Mode::Select => "SEL", Mode::Select => &modenames.select,
Mode::Normal => "NOR", Mode::Normal => &modenames.normal,
} }
} else { } else {
// If not focused, explicitly leave an empty space instead of returning None. // If not focused, explicitly leave an empty space instead of returning None.
" " " "
} }
), ),
if visible && context.editor.config().color_modes { if visible && config.color_modes {
match context.editor.mode() { match context.editor.mode() {
Mode::Insert => Some(context.editor.theme.get("ui.statusline.insert")), Mode::Insert => Some(context.editor.theme.get("ui.statusline.insert")),
Mode::Select => Some(context.editor.theme.get("ui.statusline.select")), Mode::Select => Some(context.editor.theme.get("ui.statusline.select")),
@ -241,6 +250,48 @@ where
} }
} }
fn render_workspace_diagnostics<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let (warnings, errors) =
context
.editor
.diagnostics
.values()
.flatten()
.fold((0, 0), |mut counts, diag| {
match diag.severity {
Some(DiagnosticSeverity::WARNING) => counts.0 += 1,
Some(DiagnosticSeverity::ERROR) | None => counts.1 += 1,
_ => {}
}
counts
});
if warnings > 0 || errors > 0 {
write(context, format!(" {} ", "W"), None);
}
if warnings > 0 {
write(
context,
"●".to_string(),
Some(context.editor.theme.get("warning")),
);
write(context, format!(" {} ", warnings), None);
}
if errors > 0 {
write(
context,
"●".to_string(),
Some(context.editor.theme.get("error")),
);
write(context, format!(" {} ", errors), None);
}
}
fn render_selections<F>(context: &mut RenderContext, write: F) fn render_selections<F>(context: &mut RenderContext, write: F)
where where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy, F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
@ -253,6 +304,18 @@ where
); );
} }
fn render_primary_selection_length<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let tot_sel = context.doc.selection(context.view.id).primary().len();
write(
context,
format!(" {} char{} ", tot_sel, if tot_sel == 1 { "" } else { "s" }),
None,
);
}
fn get_position(context: &RenderContext) -> Position { fn get_position(context: &RenderContext) -> Position {
coords_at_pos( coords_at_pos(
context.doc.text().slice(..), context.doc.text().slice(..),
@ -276,6 +339,15 @@ where
); );
} }
fn render_total_line_numbers<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let total_line_numbers = context.doc.text().len_lines();
write(context, format!(" {} ", total_line_numbers), None);
}
fn render_position_percentage<F>(context: &mut RenderContext, write: F) fn render_position_percentage<F>(context: &mut RenderContext, write: F)
where where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy, F: Fn(&mut RenderContext, String, Option<Style>) + Copy,

@ -11,7 +11,7 @@ mod test {
use self::helpers::*; use self::helpers::*;
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn hello_world() -> anyhow::Result<()> { async fn hello_world() -> anyhow::Result<()> {
test(("#[\n|]#", "ihello world<esc>", "hello world#[|\n]#")).await?; test(("#[\n|]#", "ihello world<esc>", "hello world#[|\n]#")).await?;
Ok(()) Ok(())
@ -22,5 +22,6 @@ mod test {
mod commands; mod commands;
mod movement; mod movement;
mod prompt; mod prompt;
mod splits;
mod write; mod write;
} }

@ -1,6 +1,6 @@
use super::*; use super::*;
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn auto_indent_c() -> anyhow::Result<()> { async fn auto_indent_c() -> anyhow::Result<()> {
test_with_config( test_with_config(
Args { Args {
@ -8,6 +8,7 @@ async fn auto_indent_c() -> anyhow::Result<()> {
..Default::default() ..Default::default()
}, },
Config::default(), Config::default(),
helpers::test_syntax_conf(None),
// switches to append mode? // switches to append mode?
( (
helpers::platform_line("void foo() {#[|}]#").as_ref(), helpers::platform_line("void foo() {#[|}]#").as_ref(),

@ -1,21 +1,547 @@
use helix_core::{auto_pairs::DEFAULT_PAIRS, hashmap};
use super::*; use super::*;
#[tokio::test] const LINE_END: &str = helix_core::DEFAULT_LINE_ENDING.as_str();
async fn auto_pairs_basic() -> anyhow::Result<()> {
test(("#[\n|]#", "i(<esc>", "(#[|)]#\n")).await?;
test_with_config( fn differing_pairs() -> impl Iterator<Item = &'static (char, char)> {
Args::default(), DEFAULT_PAIRS.iter().filter(|(open, close)| open != close)
Config { }
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Enable(false), fn matching_pairs() -> impl Iterator<Item = &'static (char, char)> {
..Default::default() DEFAULT_PAIRS.iter().filter(|(open, close)| open == close)
}, }
#[tokio::test(flavor = "multi_thread")]
async fn insert_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[{}|]#", LINE_END),
format!("i{}", pair.0),
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_configured_multi_byte_chars() -> anyhow::Result<()> {
// NOTE: these are multi-byte Unicode characters
let pairs = hashmap!('„' => '“', '' => '', '「' => '」');
let config = Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Pairs(pairs.clone()),
..Default::default() ..Default::default()
}, },
("#[\n|]#", "i(<esc>", "(#[|\n]#"), ..Default::default()
) };
.await?;
for (open, close) in pairs.iter() {
test_with_config(
Args::default(),
config.clone(),
helpers::test_syntax_conf(None),
(
format!("#[{}|]#", LINE_END),
format!("i{}", open),
format!("{}#[|{}]#{}", open, close, LINE_END),
),
)
.await?;
test_with_config(
Args::default(),
config.clone(),
helpers::test_syntax_conf(None),
(
format!("{}#[{}|]#{}", open, close, LINE_END),
format!("i{}", close),
format!("{}{}#[|{}]#", open, close, LINE_END),
),
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_after_word() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("foo#[{}|]#", LINE_END),
format!("i{}", pair.0),
format!("foo{}#[|{}]#{}", pair.0, pair.1, LINE_END),
))
.await?;
}
for pair in matching_pairs() {
test((
format!("foo#[{}|]#", LINE_END),
format!("i{}", pair.0),
format!("foo{}#[|{}]#", pair.0, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_before_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[f|]#oo{}", LINE_END),
format!("i{}", pair.0),
format!("{}#[|f]#oo{}", pair.0, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_before_word_selection() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[foo|]#{}", LINE_END),
format!("i{}", pair.0),
format!("{}#[|foo]#{}", pair.0, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_before_word_selection_trailing_word() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("foo#[ wor|]#{}", LINE_END),
format!("i{}", pair.0),
format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_closer_selection_trailing_word() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END),
format!("i{}", pair.1),
format!("foo{}{}#[| wor]#{}", pair.0, pair.1, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_before_eol() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{0}#[{0}|]#", LINE_END),
format!("i{}", pair.0),
format!(
"{eol}{open}#[|{close}]#{eol}",
eol = LINE_END,
open = pair.0,
close = pair.1
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_auto_pairs_disabled() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test_with_config(
Args::default(),
Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Enable(false),
..Default::default()
},
..Default::default()
},
helpers::test_syntax_conf(None),
(
format!("#[{}|]#", LINE_END),
format!("i{}", pair.0),
format!("{}#[|{}]#", pair.0, LINE_END),
),
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_multi_range() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[{eol}|]##({eol}|)##({eol}|)#", eol = LINE_END),
format!("i{}", pair.0),
format!(
"{open}#[|{close}]#{eol}{open}#(|{close})#{eol}{open}#(|{close})#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_before_multi_code_point_graphemes() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("hello #[👨‍👩‍👧‍👦|]# goodbye{}", LINE_END),
format!("i{}", pair.1),
format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.1, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_at_end_of_document() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test(TestCase {
in_text: String::from(LINE_END),
in_selection: Selection::single(LINE_END.len(), LINE_END.len()),
in_keys: format!("i{}", pair.0),
out_text: format!("{}{}{}", LINE_END, pair.0, pair.1),
out_selection: Selection::single(LINE_END.len() + 1, LINE_END.len() + 2),
})
.await?;
test(TestCase {
in_text: format!("foo{}", LINE_END),
in_selection: Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()),
in_keys: format!("i{}", pair.0),
out_text: format!("foo{}{}{}", LINE_END, pair.0, pair.1),
out_selection: Selection::single(LINE_END.len() + 4, LINE_END.len() + 5),
})
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_close_inside_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"{open}#[{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
format!("i{}", pair.1),
format!(
"{open}{close}#[|{eol}]#",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_close_inside_pair_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"{open}#[{close}|]#{eol}{open}#({close}|)#{eol}{open}#({close}|)#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
format!("i{}", pair.1),
format!(
"{open}{close}#[|{eol}]#{open}{close}#(|{eol})#{open}{close}#(|{eol})#",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_nested_open_inside_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
"{open}#[{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
format!("i{}", pair.0),
format!(
"{open}{open}#[|{close}]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_nested_open_inside_pair_multi() -> anyhow::Result<()> {
for outer_pair in DEFAULT_PAIRS {
for inner_pair in DEFAULT_PAIRS {
if inner_pair.0 == outer_pair.0 {
continue;
}
test((
format!(
"{outer_open}#[{outer_close}|]#{eol}{outer_open}#({outer_close}|)#{eol}{outer_open}#({outer_close}|)#{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
eol = LINE_END
),
format!("i{}", inner_pair.0),
format!(
"{outer_open}{inner_open}#[|{inner_close}]#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
inner_open = inner_pair.0,
inner_close = inner_pair.1,
eol = LINE_END
),
))
.await?;
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[{}|]#", LINE_END),
format!("a{}", pair.0),
format!(
"#[{eol}{open}{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_multi_range() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[ |]#{eol}#( |)#{eol}#( |)#{eol}", eol = LINE_END),
format!("a{}", pair.0),
format!(
"#[ {open}{close}|]#{eol}#( {open}{close}|)#{eol}#( {open}{close}|)#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_close_inside_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"#[{open}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
format!("a{}", pair.1),
format!(
"#[{open}{close}{eol}|]#",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_close_inside_pair_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"#[{open}|]#{close}{eol}#({open}|)#{close}{eol}#({open}|)#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
format!("a{}", pair.1),
format!(
"#[{open}{close}{eol}|]##({open}{close}{eol}|)##({open}{close}{eol}|)#",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_end_of_word() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("fo#[o|]#{}", LINE_END),
format!("a{}", pair.0),
format!(
"fo#[o{open}{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_middle_of_word() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("#[wo|]#rd{}", LINE_END),
format!("a{}", pair.1),
format!("#[wo{}r|]#d{}", pair.1, LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_end_of_word_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("fo#[o|]#{eol}fo#(o|)#{eol}fo#(o|)#{eol}", eol = LINE_END),
format!("a{}", pair.0),
format!(
"fo#[o{open}{close}|]#{eol}fo#(o{open}{close}|)#{eol}fo#(o{open}{close}|)#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_inside_nested_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
"f#[oo{open}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
format!("a{}", pair.0),
format!(
"f#[oo{open}{open}{close}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_inside_nested_pair_multi() -> anyhow::Result<()> {
for outer_pair in DEFAULT_PAIRS {
for inner_pair in DEFAULT_PAIRS {
if inner_pair.0 == outer_pair.0 {
continue;
}
test((
format!(
"f#[oo{outer_open}|]#{outer_close}{eol}f#(oo{outer_open}|)#{outer_close}{eol}f#(oo{outer_open}|)#{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
eol = LINE_END
),
format!("a{}", inner_pair.0),
format!(
"f#[oo{outer_open}{inner_open}{inner_close}|]#{outer_close}{eol}f#(oo{outer_open}{inner_open}{inner_close}|)#{outer_close}{eol}f#(oo{outer_open}{inner_open}{inner_close}|)#{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
inner_open = inner_pair.0,
inner_close = inner_pair.1,
eol = LINE_END
),
))
.await?;
}
}
Ok(()) Ok(())
} }

@ -1,21 +1,26 @@
use std::{ use std::ops::RangeInclusive;
io::{Read, Write},
ops::RangeInclusive,
};
use helix_core::diagnostic::Severity; use helix_core::diagnostic::Severity;
use helix_term::application::Application; use helix_term::application::Application;
use super::*; use super::*;
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn test_write_quit_fail() -> anyhow::Result<()> { async fn test_write_quit_fail() -> anyhow::Result<()> {
let file = helpers::new_readonly_tempfile()?; let file = helpers::new_readonly_tempfile()?;
let mut app = helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?;
test_key_sequence( test_key_sequence(
&mut helpers::app_with_file(file.path())?, &mut app,
Some("ihello<esc>:wq<ret>"), Some("ihello<esc>:wq<ret>"),
Some(&|app| { Some(&|app| {
let mut docs: Vec<_> = app.editor.documents().collect();
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(Some(file.path()), doc.path().map(PathBuf::as_path));
assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1); assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1);
}), }),
false, false,
@ -25,11 +30,10 @@ async fn test_write_quit_fail() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn test_buffer_close_concurrent() -> anyhow::Result<()> { async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
test_key_sequences( test_key_sequences(
&mut Application::new(Args::default(), Config::default())?, &mut helpers::AppBuilder::new().build()?,
vec![ vec![
( (
None, None,
@ -69,8 +73,12 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
command.push_str(":buffer<minus>close<ret>"); command.push_str(":buffer<minus>close<ret>");
let mut app = helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?;
test_key_sequence( test_key_sequence(
&mut helpers::app_with_file(file.path())?, &mut app,
Some(&command), Some(&command),
Some(&|app| { Some(&|app| {
assert!(!app.editor.is_err(), "error: {:?}", app.editor.get_status()); assert!(!app.editor.is_err(), "error: {:?}", app.editor.get_status());
@ -82,17 +90,12 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
) )
.await?; .await?;
file.as_file_mut().flush()?; helpers::assert_file_has_content(file.as_file_mut(), &RANGE.end().to_string())?;
file.as_file_mut().sync_all()?;
let mut file_content = String::new();
file.as_file_mut().read_to_string(&mut file_content)?;
assert_eq!(RANGE.end().to_string(), file_content);
Ok(()) Ok(())
} }
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn test_selection_duplication() -> anyhow::Result<()> { async fn test_selection_duplication() -> anyhow::Result<()> {
// Forward // Forward
test(( test((
@ -131,3 +134,180 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
.await?; .await?;
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread")]
async fn test_goto_file_impl() -> anyhow::Result<()> {
let file = tempfile::NamedTempFile::new()?;
fn match_paths(app: &Application, matches: Vec<&str>) -> usize {
app.editor
.documents()
.filter_map(|d| d.path()?.file_name())
.filter(|n| matches.iter().any(|m| *m == n.to_string_lossy()))
.count()
}
// Single selection
test_key_sequence(
&mut AppBuilder::new().with_file(file.path(), None).build()?,
Some("ione.js<esc>%gf"),
Some(&|app| {
assert_eq!(1, match_paths(app, vec!["one.js"]));
}),
false,
)
.await?;
// Multiple selection
test_key_sequence(
&mut AppBuilder::new().with_file(file.path(), None).build()?,
Some("ione.js<ret>two.js<esc>%<A-s>gf"),
Some(&|app| {
assert_eq!(2, match_paths(app, vec!["one.js", "two.js"]));
}),
false,
)
.await?;
// Cursor on first quote
test_key_sequence(
&mut AppBuilder::new().with_file(file.path(), None).build()?,
Some("iimport 'one.js'<esc>B;gf"),
Some(&|app| {
assert_eq!(1, match_paths(app, vec!["one.js"]));
}),
false,
)
.await?;
// Cursor on last quote
test_key_sequence(
&mut AppBuilder::new().with_file(file.path(), None).build()?,
Some("iimport 'one.js'<esc>bgf"),
Some(&|app| {
assert_eq!(1, match_paths(app, vec!["one.js"]));
}),
false,
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_multi_selection_paste() -> anyhow::Result<()> {
test((
platform_line(indoc! {"\
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"yp",
platform_line(indoc! {"\
lorem#[|lorem]#
ipsum#(|ipsum)#
dolor#(|dolor)#
"})
.as_str(),
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
// pipe
test((
platform_line(indoc! {"\
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"|echo foo<ret>",
platform_line(indoc! {"\
#[|foo
]#
#(|foo
)#
#(|foo
)#
"})
.as_str(),
))
.await?;
// insert-output
test((
platform_line(indoc! {"\
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"!echo foo<ret>",
platform_line(indoc! {"\
#[|foo
]#lorem
#(|foo
)#ipsum
#(|foo
)#dolor
"})
.as_str(),
))
.await?;
// append-output
test((
platform_line(indoc! {"\
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"<A-!>echo foo<ret>",
platform_line(indoc! {"\
lorem#[|foo
]#
ipsum#(|foo
)#
dolor#(|foo
)#
"})
.as_str(),
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_undo_redo() -> anyhow::Result<()> {
// A jumplist selection is created at a point which is undone.
//
// * 2[<space> Add two newlines at line start. We're now on line 3.
// * <C-s> Save the selection on line 3 in the jumplist.
// * u Undo the two newlines. We're now on line 1.
// * <C-o><C-i> Jump forward an back again in the jumplist. This would panic
// if the jumplist were not being updated correctly.
test(("#[|]#", "2[<space><C-s>u<C-o><C-i>", "#[|]#")).await?;
// A jumplist selection is passed through an edit and then an undo and then a redo.
//
// * [<space> Add a newline at line start. We're now on line 2.
// * <C-s> Save the selection on line 2 in the jumplist.
// * kd Delete line 1. The jumplist selection should be adjusted to the new line 1.
// * uU Undo and redo the `kd` edit.
// * <C-o> Jump back in the jumplist. This would panic if the jumplist were not being
// updated correctly.
// * <C-i> Jump forward to line 1.
test(("#[|]#", "[<space><C-s>kduU<C-o><C-i>", "#[|]#")).await?;
// In this case we 'redo' manually to ensure that the transactions are composing correctly.
test(("#[|]#", "[<space>u[<space>u", "#[|]#")).await?;
Ok(())
}

@ -1,10 +1,15 @@
use std::{io::Write, path::PathBuf, time::Duration}; use std::{
fs::File,
io::{Read, Write},
path::PathBuf,
time::Duration,
};
use anyhow::bail; use anyhow::bail;
use crossterm::event::{Event, KeyEvent}; use crossterm::event::{Event, KeyEvent};
use helix_core::{test, Selection, Transaction}; use helix_core::{diagnostic::Severity, test, Selection, Transaction};
use helix_term::{application::Application, args::Args, config::Config}; use helix_term::{application::Application, args::Args, config::Config};
use helix_view::{doc, input::parse_macro}; use helix_view::{doc, input::parse_macro, Editor};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
@ -56,7 +61,9 @@ pub async fn test_key_sequences(
for (i, (in_keys, test_fn)) in inputs.into_iter().enumerate() { for (i, (in_keys, test_fn)) in inputs.into_iter().enumerate() {
if let Some(in_keys) = in_keys { if let Some(in_keys) = in_keys {
for key_event in parse_macro(in_keys)?.into_iter() { for key_event in parse_macro(in_keys)?.into_iter() {
tx.send(Ok(Event::Key(KeyEvent::from(key_event))))?; let key = Event::Key(KeyEvent::from(key_event));
log::trace!("sending key: {:?}", key);
tx.send(Ok(key))?;
} }
} }
@ -70,7 +77,7 @@ pub async fn test_key_sequences(
// verify if it exited on the last iteration if it should have and // verify if it exited on the last iteration if it should have and
// the inverse // the inverse
if i == num_inputs - 1 && app_exited != should_exit { if i == num_inputs - 1 && app_exited != should_exit {
bail!("expected app to exit: {} != {}", app_exited, should_exit); bail!("expected app to exit: {} != {}", should_exit, app_exited);
} }
if let Some(test) = test_fn { if let Some(test) = test_fn {
@ -87,7 +94,17 @@ pub async fn test_key_sequences(
tokio::time::timeout(TIMEOUT, event_loop).await?; tokio::time::timeout(TIMEOUT, event_loop).await?;
} }
app.close().await?; let errs = app.close().await;
if !errs.is_empty() {
log::error!("Errors closing app");
for err in errs {
log::error!("{}", err);
}
bail!("Error closing app");
}
Ok(()) Ok(())
} }
@ -101,20 +118,19 @@ pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>(
let test_case = test_case.into(); let test_case = test_case.into();
let mut app = match app { let mut app = match app {
Some(app) => app, Some(app) => app,
None => Application::new(Args::default(), Config::default())?, None => Application::new(Args::default(), Config::default(), test_syntax_conf(None))?,
}; };
let (view, doc) = helix_view::current!(app.editor); let (view, doc) = helix_view::current!(app.editor);
let sel = doc.selection(view.id).clone(); let sel = doc.selection(view.id).clone();
// replace the initial text with the input text // replace the initial text with the input text
doc.apply( let transaction = Transaction::change_by_selection(doc.text(), &sel, |_| {
&Transaction::change_by_selection(doc.text(), &sel, |_| { (0, doc.text().len_chars(), Some((&test_case.in_text).into()))
(0, doc.text().len_chars(), Some((&test_case.in_text).into())) })
}) .with_selection(test_case.in_selection.clone());
.with_selection(test_case.in_selection.clone()),
view.id, helix_view::apply_transaction(&transaction, doc, view);
);
test_key_sequence( test_key_sequence(
&mut app, &mut app,
@ -125,16 +141,48 @@ pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>(
.await .await
} }
/// Generates language configs that merge in overrides, like a user language
/// config. The argument string must be a raw TOML document.
///
/// By default, language server configuration is dropped from the languages.toml
/// document. If a language-server is necessary for a test, it must be explicitly
/// added in `overrides`.
pub fn test_syntax_conf(overrides: Option<String>) -> helix_core::syntax::Configuration {
let mut lang = helix_loader::config::default_lang_config();
for lang_config in lang
.as_table_mut()
.expect("Expected languages.toml to be a table")
.get_mut("language")
.expect("Expected languages.toml to have \"language\" keys")
.as_array_mut()
.expect("Expected an array of language configurations")
{
lang_config
.as_table_mut()
.expect("Expected language config to be a TOML table")
.remove("language-server");
}
if let Some(overrides) = overrides {
let override_toml = toml::from_str(&overrides).unwrap();
lang = helix_loader::merge_toml_values(lang, override_toml, 3);
}
lang.try_into().unwrap()
}
/// Use this for very simple test cases where there is one input /// Use this for very simple test cases where there is one input
/// document, selection, and sequence of key presses, and you just /// document, selection, and sequence of key presses, and you just
/// want to verify the resulting document and selection. /// want to verify the resulting document and selection.
pub async fn test_with_config<T: Into<TestCase>>( pub async fn test_with_config<T: Into<TestCase>>(
args: Args, args: Args,
config: Config, config: Config,
syn_conf: helix_core::syntax::Configuration,
test_case: T, test_case: T,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let test_case = test_case.into(); let test_case = test_case.into();
let app = Application::new(args, config)?; let app = Application::new(args, config, syn_conf)?;
test_key_sequence_with_input_text( test_key_sequence_with_input_text(
Some(app), Some(app),
@ -155,7 +203,13 @@ pub async fn test_with_config<T: Into<TestCase>>(
} }
pub async fn test<T: Into<TestCase>>(test_case: T) -> anyhow::Result<()> { pub async fn test<T: Into<TestCase>>(test_case: T) -> anyhow::Result<()> {
test_with_config(Args::default(), Config::default(), test_case).await test_with_config(
Args::default(),
Config::default(),
test_syntax_conf(None),
test_case,
)
.await
} }
pub fn temp_file_with_contents<S: AsRef<str>>( pub fn temp_file_with_contents<S: AsRef<str>>(
@ -200,14 +254,87 @@ pub fn new_readonly_tempfile() -> anyhow::Result<NamedTempFile> {
Ok(file) Ok(file)
} }
/// Creates a new Application with default config that opens the given file pub struct AppBuilder {
/// path args: Args,
pub fn app_with_file<P: Into<PathBuf>>(path: P) -> anyhow::Result<Application> { config: Config,
Application::new( syn_conf: helix_core::syntax::Configuration,
Args { input: Option<(String, Selection)>,
files: vec![(path.into(), helix_core::Position::default())], }
..Default::default()
}, impl Default for AppBuilder {
Config::default(), fn default() -> Self {
) Self {
args: Args::default(),
config: Config::default(),
syn_conf: test_syntax_conf(None),
input: None,
}
}
}
impl AppBuilder {
pub fn new() -> Self {
AppBuilder::default()
}
pub fn with_file<P: Into<PathBuf>>(
mut self,
path: P,
pos: Option<helix_core::Position>,
) -> Self {
self.args.files.push((path.into(), pos.unwrap_or_default()));
self
}
// Remove this attribute once `with_config` is used in a test:
#[allow(dead_code)]
pub fn with_config(mut self, config: Config) -> Self {
self.config = config;
self
}
pub fn with_input_text<S: Into<String>>(mut self, input_text: S) -> Self {
self.input = Some(test::print(&input_text.into()));
self
}
pub fn with_lang_config(mut self, syn_conf: helix_core::syntax::Configuration) -> Self {
self.syn_conf = syn_conf;
self
}
pub fn build(self) -> anyhow::Result<Application> {
let mut app = Application::new(self.args, self.config, self.syn_conf)?;
if let Some((text, selection)) = self.input {
let (view, doc) = helix_view::current!(app.editor);
let sel = doc.selection(view.id).clone();
let trans = Transaction::change_by_selection(doc.text(), &sel, |_| {
(0, doc.text().len_chars(), Some((text.clone()).into()))
})
.with_selection(selection);
// replace the initial text with the input text
helix_view::apply_transaction(&trans, doc, view);
}
Ok(app)
}
}
pub fn assert_file_has_content(file: &mut File, content: &str) -> anyhow::Result<()> {
file.flush()?;
file.sync_all()?;
let mut file_content = String::new();
file.read_to_string(&mut file_content)?;
assert_eq!(content, file_content);
Ok(())
}
pub fn assert_status_not_error(editor: &Editor) {
if let Some((_, sev)) = editor.get_status() {
assert_ne!(&Severity::Error, sev);
}
} }

@ -1,6 +1,6 @@
use super::*; use super::*;
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn insert_mode_cursor_position() -> anyhow::Result<()> { async fn insert_mode_cursor_position() -> anyhow::Result<()> {
test(TestCase { test(TestCase {
in_text: String::new(), in_text: String::new(),
@ -19,7 +19,7 @@ async fn insert_mode_cursor_position() -> anyhow::Result<()> {
} }
/// Range direction is preserved when escaping insert mode to normal /// Range direction is preserved when escaping insert mode to normal
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> { async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> {
test(("#[f|]#oo\n", "vll<A-;><esc>", "#[|foo]#\n")).await?; test(("#[f|]#oo\n", "vll<A-;><esc>", "#[|foo]#\n")).await?;
test(( test((
@ -66,11 +66,13 @@ async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> {
/// Ensure the very initial cursor in an opened file is the width of /// Ensure the very initial cursor in an opened file is the width of
/// the first grapheme /// the first grapheme
#[tokio::test] #[tokio::test(flavor = "multi_thread")]
async fn cursor_position_newly_opened_file() -> anyhow::Result<()> { async fn cursor_position_newly_opened_file() -> anyhow::Result<()> {
let test = |content: &str, expected_sel: Selection| -> anyhow::Result<()> { let test = |content: &str, expected_sel: Selection| -> anyhow::Result<()> {
let file = helpers::temp_file_with_contents(content)?; let file = helpers::temp_file_with_contents(content)?;
let mut app = helpers::app_with_file(file.path())?; let mut app = helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?;
let (view, doc) = helix_view::current!(app.editor); let (view, doc) = helix_view::current!(app.editor);
let sel = doc.selection(view.id).clone(); let sel = doc.selection(view.id).clone();
@ -85,3 +87,156 @@ async fn cursor_position_newly_opened_file() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread")]
async fn cursor_position_append_eof() -> anyhow::Result<()> {
// Selection is fowards
test((
"#[foo|]#",
"abar<esc>",
helpers::platform_line("#[foobar|]#\n").as_ref(),
))
.await?;
// Selection is backwards
test((
"#[|foo]#",
"abar<esc>",
helpers::platform_line("#[foobar|]#\n").as_ref(),
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn select_mode_tree_sitter_next_function_is_union_of_objects() -> anyhow::Result<()> {
test_with_config(
Args {
files: vec![(PathBuf::from("foo.rs"), Position::default())],
..Default::default()
},
Config::default(),
helpers::test_syntax_conf(None),
(
helpers::platform_line(indoc! {"\
#[/|]#// Increments
fn inc(x: usize) -> usize { x + 1 }
/// Decrements
fn dec(x: usize) -> usize { x - 1 }
"})
.as_ref(),
"]fv]f",
helpers::platform_line(indoc! {"\
/// Increments
#[fn inc(x: usize) -> usize { x + 1 }
/// Decrements
fn dec(x: usize) -> usize { x - 1 }|]#
"})
.as_ref(),
),
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn select_mode_tree_sitter_prev_function_unselects_object() -> anyhow::Result<()> {
test_with_config(
Args {
files: vec![(PathBuf::from("foo.rs"), Position::default())],
..Default::default()
},
Config::default(),
helpers::test_syntax_conf(None),
(
helpers::platform_line(indoc! {"\
/// Increments
#[fn inc(x: usize) -> usize { x + 1 }
/// Decrements
fn dec(x: usize) -> usize { x - 1 }|]#
"})
.as_ref(),
"v[f",
helpers::platform_line(indoc! {"\
/// Increments
#[fn inc(x: usize) -> usize { x + 1 }|]#
/// Decrements
fn dec(x: usize) -> usize { x - 1 }
"})
.as_ref(),
),
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn select_mode_tree_sitter_prev_function_goes_backwards_to_object() -> anyhow::Result<()> {
// Note: the anchor stays put and the head moves back.
test_with_config(
Args {
files: vec![(PathBuf::from("foo.rs"), Position::default())],
..Default::default()
},
Config::default(),
helpers::test_syntax_conf(None),
(
helpers::platform_line(indoc! {"\
/// Increments
fn inc(x: usize) -> usize { x + 1 }
/// Decrements
fn dec(x: usize) -> usize { x - 1 }
/// Identity
#[fn ident(x: usize) -> usize { x }|]#
"})
.as_ref(),
"v[f",
helpers::platform_line(indoc! {"\
/// Increments
fn inc(x: usize) -> usize { x + 1 }
/// Decrements
#[|fn dec(x: usize) -> usize { x - 1 }
/// Identity
]#fn ident(x: usize) -> usize { x }
"})
.as_ref(),
),
)
.await?;
test_with_config(
Args {
files: vec![(PathBuf::from("foo.rs"), Position::default())],
..Default::default()
},
Config::default(),
helpers::test_syntax_conf(None),
(
helpers::platform_line(indoc! {"\
/// Increments
fn inc(x: usize) -> usize { x + 1 }
/// Decrements
fn dec(x: usize) -> usize { x - 1 }
/// Identity
#[fn ident(x: usize) -> usize { x }|]#
"})
.as_ref(),
"v[f[f",
helpers::platform_line(indoc! {"\
/// Increments
#[|fn inc(x: usize) -> usize { x + 1 }
/// Decrements
fn dec(x: usize) -> usize { x - 1 }
/// Identity
]#fn ident(x: usize) -> usize { x }
"})
.as_ref(),
),
)
.await?;
Ok(())
}

@ -1,11 +1,9 @@
use super::*; use super::*;
use helix_term::application::Application; #[tokio::test(flavor = "multi_thread")]
#[tokio::test]
async fn test_history_completion() -> anyhow::Result<()> { async fn test_history_completion() -> anyhow::Result<()> {
test_key_sequence( test_key_sequence(
&mut Application::new(Args::default(), Config::default())?, &mut AppBuilder::new().build()?,
Some(":asdf<ret>:theme d<C-n><tab>"), Some(":asdf<ret>:theme d<C-n><tab>"),
Some(&|app| { Some(&|app| {
assert!(!app.editor.is_err()); assert!(!app.editor.is_err());

@ -0,0 +1,190 @@
use super::*;
#[tokio::test(flavor = "multi_thread")]
async fn test_split_write_quit_all() -> anyhow::Result<()> {
let mut file1 = tempfile::NamedTempFile::new()?;
let mut file2 = tempfile::NamedTempFile::new()?;
let mut file3 = tempfile::NamedTempFile::new()?;
let mut app = helpers::AppBuilder::new()
.with_file(file1.path(), None)
.build()?;
test_key_sequences(
&mut app,
vec![
(
Some(&format!(
"ihello1<esc>:sp<ret>:o {}<ret>ihello2<esc>:sp<ret>:o {}<ret>ihello3<esc>",
file2.path().to_string_lossy(),
file3.path().to_string_lossy()
)),
Some(&|app| {
let docs: Vec<_> = app.editor.documents().collect();
assert_eq!(3, docs.len());
let doc1 = docs
.iter()
.find(|doc| doc.path().unwrap() == file1.path())
.unwrap();
assert_eq!("hello1", doc1.text().to_string());
let doc2 = docs
.iter()
.find(|doc| doc.path().unwrap() == file2.path())
.unwrap();
assert_eq!("hello2", doc2.text().to_string());
let doc3 = docs
.iter()
.find(|doc| doc.path().unwrap() == file3.path())
.unwrap();
assert_eq!("hello3", doc3.text().to_string());
helpers::assert_status_not_error(&app.editor);
assert_eq!(3, app.editor.tree.views().count());
}),
),
(
Some(":wqa<ret>"),
Some(&|app| {
helpers::assert_status_not_error(&app.editor);
assert_eq!(0, app.editor.tree.views().count());
}),
),
],
true,
)
.await?;
helpers::assert_file_has_content(file1.as_file_mut(), "hello1")?;
helpers::assert_file_has_content(file2.as_file_mut(), "hello2")?;
helpers::assert_file_has_content(file3.as_file_mut(), "hello3")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_split_write_quit_same_file() -> anyhow::Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
let mut app = helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?;
test_key_sequences(
&mut app,
vec![
(
Some("O<esc>ihello<esc>:sp<ret>ogoodbye<esc>"),
Some(&|app| {
assert_eq!(2, app.editor.tree.views().count());
helpers::assert_status_not_error(&app.editor);
let mut docs: Vec<_> = app.editor.documents().collect();
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(
helpers::platform_line("hello\ngoodbye"),
doc.text().to_string()
);
assert!(doc.is_modified());
}),
),
(
Some(":wq<ret>"),
Some(&|app| {
helpers::assert_status_not_error(&app.editor);
assert_eq!(1, app.editor.tree.views().count());
let mut docs: Vec<_> = app.editor.documents().collect();
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(
helpers::platform_line("hello\ngoodbye"),
doc.text().to_string()
);
assert!(!doc.is_modified());
}),
),
],
false,
)
.await?;
helpers::assert_file_has_content(
file.as_file_mut(),
&helpers::platform_line("hello\ngoodbye"),
)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_changes_in_splits_apply_to_all_views() -> anyhow::Result<()> {
// See <https://github.com/helix-editor/helix/issues/4732>.
// Transactions must be applied to any view that has the changed document open.
// This sequence would panic since the jumplist entry would be modified in one
// window but not the other. Attempting to update the changelist in the other
// window would cause a panic since it would point outside of the document.
// The key sequence here:
// * <C-w>v Create a vertical split of the current buffer.
// Both views look at the same doc.
// * [<space> Add a line ending to the beginning of the document.
// The cursor is now at line 2 in window 2.
// * <C-s> Save that selection to the jumplist in window 2.
// * <C-w>w Switch to window 1.
// * kd Delete line 1 in window 1.
// * <C-w>q Close window 1, focusing window 2.
// * d Delete line 1 in window 2.
//
// This panicked in the past because the jumplist entry on line 2 of window 2
// was not updated and after the `kd` step, pointed outside of the document.
test(("#[|]#", "<C-w>v[<space><C-s><C-w>wkd<C-w>qd", "#[|]#")).await?;
// Transactions are applied to the views for windows lazily when they are focused.
// This case panics if the transactions and inversions are not applied in the
// correct order as we switch between windows.
test((
"#[|]#",
"[<space>[<space>[<space><C-w>vuuu<C-w>wUUU<C-w>quuu",
"#[|]#",
))
.await?;
// See <https://github.com/helix-editor/helix/issues/4957>.
// This sequence undoes part of the history and then adds new changes, creating a
// new branch in the history tree. `View::sync_changes` applies transactions down
// and up to the lowest common ancestor in the path between old and new revision
// numbers. If we apply these up/down transactions in the wrong order, this case
// panics.
// The key sequence:
// * 3[<space> Create three empty lines so we are at the end of the document.
// * <C-w>v<C-s> Create a split and save that point at the end of the document
// in the jumplist.
// * <C-w>w Switch back to the first window.
// * uu Undo twice (not three times which would bring us back to the
// root of the tree).
// * 3[<space> Create three empty lines. Now the end of the document is past
// where it was on step 1.
// * <C-w>q Close window 1, focusing window 2 and causing a sync. This step
// panics if we don't apply in the right order.
// * %d Clean up the buffer.
test((
"#[|]#",
"3[<space><C-w>v<C-s><C-w>wuu3[<space><C-w>q%d",
"#[|]#",
))
.await?;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save