Merge pull request 'master' (#1) from Mirrors/helix:master into master

Reviewed-on: #1
pull/3/head^2
Trivernis 1 year ago
commit 518297780b

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

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

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

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

@ -26,18 +26,12 @@ jobs:
uses: actions/checkout@v3
- name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1
with:
profile: minimal
override: true
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v1
- uses: Swatinem/rust-cache@v2
- name: Fetch tree-sitter grammars
uses: actions-rs/cargo@v1
with:
command: run
args: --package=helix-loader --bin=hx-loader
run: cargo run --package=helix-loader --bin=hx-loader
- name: Bundle grammars
run: tar cJf grammars.tar.xz -C runtime/grammars/sources .
@ -50,6 +44,16 @@ jobs:
dist:
name: Dist
needs: [fetch-grammars]
env:
# For some builds, we use cross to test on 32-bit and big-endian
# systems.
CARGO: cargo
# When CARGO is set to CROSS, this is set to `--target matrix.target`.
TARGET_FLAGS:
# When CARGO is set to CROSS, TARGET_DIR includes matrix.target.
TARGET_DIR: ./target
# Emit backtraces on panics.
RUST_BACKTRACE: 1
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # don't fail other jobs if one fails
@ -57,17 +61,17 @@ jobs:
build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc
include:
- build: x86_64-linux
os: ubuntu-20.04
os: ubuntu-latest
rust: stable
target: x86_64-unknown-linux-gnu
cross: false
- build: aarch64-linux
os: ubuntu-20.04
os: ubuntu-latest
rust: stable
target: aarch64-unknown-linux-gnu
cross: true
- build: riscv64-linux
os: ubuntu-20.04
os: ubuntu-latest
rust: stable
target: riscv64gc-unknown-linux-gnu
cross: true
@ -77,7 +81,7 @@ jobs:
target: x86_64-apple-darwin
cross: false
- build: x86_64-windows
os: windows-2019
os: windows-latest
rust: stable
target: x86_64-pc-windows-msvc
cross: false
@ -110,12 +114,10 @@ jobs:
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
- name: Install ${{ matrix.rust }} toolchain
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@master
with:
profile: minimal
toolchain: ${{ matrix.rust }}
target: ${{ matrix.target }}
override: true
# Install a pre-release version of Cross
# TODO: We need to pre-install Cross because we need cross-rs/cross#591 to
@ -123,15 +125,20 @@ jobs:
# 0.3.0, which includes cross-rs/cross#591, is released.
- name: Install Cross
if: "matrix.cross"
run: cargo install cross --git https://github.com/cross-rs/cross.git --rev 47df5c76e7cba682823a0b6aa6d95c17b31ba63a
run: |
cargo install cross --git https://github.com/cross-rs/cross.git --rev 47df5c76e7cba682823a0b6aa6d95c17b31ba63a
echo "CARGO=cross" >> $GITHUB_ENV
# echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV
# echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV
- name: Show command used for Cargo
run: |
echo "cargo command is: ${{ env.CARGO }}"
echo "target flag is: ${{ env.TARGET_FLAGS }}"
- name: Run cargo test
uses: actions-rs/cargo@v1
if: "!matrix.skip_tests"
with:
use-cross: ${{ matrix.cross }}
command: test
args: --release --locked --target ${{ matrix.target }} --workspace
run: ${{ env.CARGO }} test --release --locked --target ${{ matrix.target }} --workspace
- name: Set profile.release.strip = true
shell: bash
@ -142,11 +149,7 @@ jobs:
EOF
- name: Build release binary
uses: actions-rs/cargo@v1
with:
use-cross: ${{ matrix.cross }}
command: build
args: --release --locked --target ${{ matrix.target }}
run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }}
- name: Build AppImage
shell: bash
@ -221,16 +224,6 @@ jobs:
- uses: actions/download-artifact@v3
- name: Calculate tag name
run: |
name=dev
if [[ $GITHUB_REF == refs/tags/* ]]; then
name=${GITHUB_REF:10}
fi
echo ::set-output name=val::$name
echo TAG=$name >> $GITHUB_ENV
id: tagname
- name: Build archive
shell: bash
run: |
@ -250,7 +243,7 @@ jobs:
if [[ $platform =~ "windows" ]]; then
exe=".exe"
fi
pkgname=helix-$TAG-$platform
pkgname=helix-$GITHUB_REF_NAME-$platform
mkdir $pkgname
cp $source/LICENSE $source/README.md $pkgname
mkdir $pkgname/contrib
@ -270,7 +263,7 @@ jobs:
fi
done
tar cJf dist/helix-$TAG-source.tar.xz -C $source .
tar cJf dist/helix-$GITHUB_REF_NAME-source.tar.xz -C $source .
mv dist $source/
- name: Upload binaries to release
@ -280,7 +273,7 @@ jobs:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: dist/*
file_glob: true
tag: ${{ steps.tagname.outputs.val }}
tag: ${{ github.ref_name }}
overwrite: true
- name: Upload binaries as artifact

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

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

@ -1 +1 @@
22.08.1
22.12

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

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

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

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

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

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

@ -50,6 +50,41 @@ sudo dnf install helix
sudo xbps-install helix
```
## Windows
Helix can be installed using [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
or [MSYS2](https://msys2.org/).
**Scoop:**
```
scoop install helix
```
**Chocolatey:**
```
choco install helix
```
**MSYS2:**
```
pacman -S mingw-w64-i686-helix
```
or
```
pacman -S mingw-w64-x86_64-helix
```
or
```
pacman -S mingw-w64-ucrt-x86_64-helix
```
## Build from source
```
@ -58,26 +93,67 @@ cd helix
cargo install --path helix-term
```
This will install the `hx` binary to `$HOME/.cargo/bin`.
This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`.
Helix also needs it's runtime files so make sure to copy/symlink the `runtime/` directory into the
Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the
config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden
via the `HELIX_RUNTIME` environment variable.
| OS | command |
|-------------------|-----------|
|windows(cmd.exe) |`xcopy /e /i runtime %AppData%/helix/runtime` |
|windows(powershell)|`xcopy /e /i runtime $Env:AppData\helix\runtime` |
|linux/macos |`ln -s $PWD/runtime ~/.config/helix/runtime`|
| OS | Command |
| -------------------- | ------------------------------------------------ |
| Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` |
| Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` |
| Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` |
Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires
elevated privileges - i.e. PowerShell or Cmd must be run as administrator.
**PowerShell:**
```powershell
New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime"
```
**Cmd:**
```cmd
cd %appdata%\helix
mklink /D runtime "<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
```
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

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

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

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

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

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

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">
<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 -->
<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>

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 -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config" -- $2))
COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- $2))
;;
esac
} && complete -F _hx hx
} && complete -o filenames -F _hx hx

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

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

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

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

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

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

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

@ -7,7 +7,6 @@ use std::collections::HashMap;
use smallvec::SmallVec;
// Heavily based on https://github.com/codemirror/closebrackets/
pub const DEFAULT_PAIRS: &[(char, char)] = &[
('(', ')'),
('{', '}'),
@ -147,13 +146,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
}
/// calculate what the resulting range should be for an auto pair insertion
fn get_next_range(
doc: &Rope,
start_range: &Range,
offset: usize,
typed_char: char,
len_inserted: usize,
) -> Range {
fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range {
// When the character under the cursor changes due to complete pair
// insertion, we must look backward a grapheme and then add the length
// of the insertion to put the resulting cursor in the right place, e.g.
@ -173,8 +166,8 @@ fn get_next_range(
// inserting at the very end of the document after the last newline
if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() {
return Range::new(
start_range.anchor + offset + typed_char.len_utf8(),
start_range.head + offset + typed_char.len_utf8(),
start_range.anchor + offset + 1,
start_range.head + offset + 1,
);
}
@ -204,21 +197,18 @@ fn get_next_range(
// trivial case: only inserted a single-char opener, just move the selection
if len_inserted == 1 {
let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward {
start_range.anchor + offset + typed_char.len_utf8()
start_range.anchor + offset + 1
} else {
start_range.anchor + offset
};
return Range::new(
end_anchor,
start_range.head + offset + typed_char.len_utf8(),
);
return Range::new(end_anchor, start_range.head + offset + 1);
}
// If the head = 0, then we must be in insert mode with a backward
// cursor, which implies the head will just move
let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward {
start_range.head + offset + typed_char.len_utf8()
start_range.head + offset + 1
} else {
// We must have a forward cursor, which means we must move to the
// other end of the grapheme to get to where the new characters
@ -244,8 +234,7 @@ fn get_next_range(
(_, Direction::Forward) => {
if single_grapheme {
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head)
+ typed_char.len_utf8()
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) + 1
// if we are appending, the anchor stays where it is; only offset
// for multiple range insertions
@ -259,7 +248,9 @@ fn get_next_range(
// if we're backward, then the head is at the first char
// of the typed char, so we need to add the length of
// the closing char
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor)
+ len_inserted
+ offset
} else {
// when we are inserting in front of a selection, we need to move
// the anchor over by however many characters were inserted overall
@ -280,9 +271,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let next_char = doc.get_char(cursor);
let len_inserted;
// Since auto pairs are currently limited to single chars, we're either
// inserting exactly one or two chars. When arbitrary length pairs are
// added, these will need to be changed.
let change = match next_char {
Some(_) if !pair.should_close(doc, start_range) => {
len_inserted = pair.open.len_utf8();
len_inserted = 1;
let mut tendril = Tendril::new();
tendril.push(pair.open);
(cursor, cursor, Some(tendril))
@ -290,12 +284,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
_ => {
// insert open & close
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = pair.open.len_utf8() + pair.close.len_utf8();
len_inserted = 2;
(cursor, cursor, Some(pair_str))
}
};
let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
@ -309,7 +303,6 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0;
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
@ -321,13 +314,13 @@ fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
len_inserted += pair.close.len_utf8();
len_inserted = 1;
let mut tendril = Tendril::new();
tendril.push(pair.close);
(cursor, cursor, Some(tendril))
};
let next_range = get_next_range(doc, start_range, offs, pair.close, len_inserted);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
@ -363,11 +356,11 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
pair_str.push(pair.close);
}
len_inserted += pair_str.len();
len_inserted += pair_str.chars().count();
(cursor, cursor, Some(pair_str))
};
let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
@ -378,551 +371,3 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
log::debug!("auto pair transaction: {:#?}", t);
t
}
#[cfg(test)]
mod test {
use super::*;
use smallvec::smallvec;
const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str();
fn differing_pairs() -> impl Iterator<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]
fn test_find_line_comment() {
use crate::State;
// four lines, two space indented, except for line 1 which is blank.
let doc = Rope::from(" 1\n\n 2\n 3");
let mut state = State::new(doc);
let mut doc = Rope::from(" 1\n\n 2\n 3");
// select whole document
state.selection = Selection::single(0, state.doc.len_chars() - 1);
let mut selection = Selection::single(0, doc.len_chars() - 1);
let text = state.doc.slice(..);
let text = doc.slice(..);
let res = find_line_comment("//", text, 0..3);
// (commented = true, to_change = [line 0, line 2], min = col 2, margin = 1)
assert_eq!(res, (false, vec![0, 2], 2, 1));
// comment
let transaction = toggle_line_comments(&state.doc, &state.selection, None);
transaction.apply(&mut state.doc);
state.selection = state.selection.map(transaction.changes());
let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut doc);
selection = selection.map(transaction.changes());
assert_eq!(state.doc, " // 1\n\n // 2\n // 3");
assert_eq!(doc, " // 1\n\n // 2\n // 3");
// uncomment
let transaction = toggle_line_comments(&state.doc, &state.selection, None);
transaction.apply(&mut state.doc);
state.selection = state.selection.map(transaction.changes());
assert_eq!(state.doc, " 1\n\n 2\n 3");
let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut doc);
selection = selection.map(transaction.changes());
assert_eq!(doc, " 1\n\n 2\n 3");
assert!(selection.len() == 1); // to ignore the selection unused warning
// 0 margin comments
state.doc = Rope::from(" //1\n\n //2\n //3");
doc = Rope::from(" //1\n\n //2\n //3");
// reset the selection.
state.selection = Selection::single(0, state.doc.len_chars() - 1);
selection = Selection::single(0, doc.len_chars() - 1);
let transaction = toggle_line_comments(&state.doc, &state.selection, None);
transaction.apply(&mut state.doc);
state.selection = state.selection.map(transaction.changes());
assert_eq!(state.doc, " 1\n\n 2\n 3");
let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut doc);
selection = selection.map(transaction.changes());
assert_eq!(doc, " 1\n\n 2\n 3");
assert!(selection.len() == 1); // to ignore the selection unused warning
// TODO: account for uncommenting with uneven comment indentation
}

@ -29,6 +29,12 @@ pub enum NumberOrString {
String(String),
}
#[derive(Debug, Clone)]
pub enum DiagnosticTag {
Unnecessary,
Deprecated,
}
/// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html)
#[derive(Debug, Clone)]
pub struct Diagnostic {
@ -37,4 +43,7 @@ pub struct Diagnostic {
pub message: String,
pub severity: Option<Severity>,
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
/// the steps required to get from `old` to `new`.
pub fn compare_ropes(old: &Rope, new: &Rope) -> Transaction {
// `similar` only works on contiguous data, so a `Rope` has
// to be temporarily converted into a `String`.
let old_converted = old.to_string();
let new_converted = new.to_string();
// A timeout is set so after 1 seconds, the algorithm will start
// approximating. This is especially important for big `Rope`s or
// `Rope`s that are extremely dissimilar to each other.
let mut config = similar::TextDiff::configure();
config.timeout(std::time::Duration::from_secs(1));
let diff = config.diff_chars(&old_converted, &new_converted);
// The current position of the change needs to be tracked to
// construct the `Change`s.
let mut pos = 0;
Transaction::change(
old,
diff.ops()
use imara_diff::intern::InternedInput;
use imara_diff::Algorithm;
use ropey::RopeSlice;
use crate::{ChangeSet, Rope, Tendril, Transaction};
/// A `imara_diff::Sink` that builds a `ChangeSet` for a character diff of a hunk
struct CharChangeSetBuilder<'a> {
res: &'a mut ChangeSet,
hunk: &'a InternedInput<char>,
pos: u32,
}
impl imara_diff::Sink for CharChangeSetBuilder<'_> {
type Out = ();
fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
self.res.retain((before.start - self.pos) as usize);
self.res.delete(before.len());
self.pos = before.end;
let res = self.hunk.after[after.start as usize..after.end as usize]
.iter()
.map(|&token| self.hunk.interner[token])
.collect();
self.res.insert(res);
}
fn finish(self) -> Self::Out {
self.res.retain(self.hunk.before.len() - self.pos as usize);
}
}
struct LineChangeSetBuilder<'a> {
res: ChangeSet,
after: RopeSlice<'a>,
file: &'a InternedInput<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()
.map(|op| op.as_tag_tuple())
.filter_map(|(tag, old_range, new_range)| {
// `old_pos..pos` is equivalent to `start..end` for where
// the change should be applied.
let old_pos = pos;
pos += old_range.end - old_range.start;
match tag {
// Semantically, inserts and replacements are the same thing.
similar::DiffTag::Insert | similar::DiffTag::Replace => {
// This is the text from the `new` rope that should be
// inserted into `old`.
let text: &str = {
let start = new.char_to_byte(new_range.start);
let end = new.char_to_byte(new_range.end);
&new_converted[start..end]
};
Some((old_pos, pos, Some(text.into())))
.map(|&it| self.file.interner[it].len_chars())
.sum();
self.res.retain(len);
self.pos = before.end;
// do not perform diffs on large hunks
let len_before = before.end - before.start;
let len_after = after.end - after.start;
// Pure insertions/removals do not require a character diff.
// Very large changes are ignored because their character diff is expensive to compute
// TODO adjust heuristic to detect large changes?
if len_before == 0
|| len_after == 0
|| len_after > 5 * len_before
|| 5 * len_after < len_before && len_before > 10
|| len_before + len_after > 200
{
let remove = self.file.before[before.start as usize..before.end as usize]
.iter()
.map(|&it| self.file.interner[it].len_chars())
.sum();
self.res.delete(remove);
let mut fragment = Tendril::new();
if len_after > 500 {
// copying a rope line by line is slower then copying the entire
// rope. Use to_string for very large changes instead..
if self.file.after.len() == after.end as usize {
if after.start == 0 {
fragment = self.after.to_string().into();
} else {
let start = self.after.line_to_char(after.start as usize);
fragment = self.after.slice(start..).to_string().into();
}
similar::DiffTag::Delete => Some((old_pos, pos, None)),
similar::DiffTag::Equal => None,
} else if after.start == 0 {
let end = self.after.line_to_char(after.end as usize);
fragment = self.after.slice(..end).to_string().into();
} else {
let start = self.after.line_to_char(after.start as usize);
let end = self.after.line_to_char(after.end as usize);
fragment = self.after.slice(start..end).to_string().into();
}
}),
)
} else {
for &line in &self.file.after[after.start as usize..after.end as usize] {
for chunk in self.file.interner[line].chunks() {
fragment.push_str(chunk)
}
}
};
self.res.insert(fragment);
} else {
// for reasonably small hunks, generating a ChangeSet from char diff can save memory
// TODO use a tokenizer (word diff?) for improved performance
let hunk_before = self.file.before[before.start as usize..before.end as usize]
.iter()
.flat_map(|&it| self.file.interner[it].chars());
let hunk_after = self.file.after[after.start as usize..after.end as usize]
.iter()
.flat_map(|&it| self.file.interner[it].chars());
self.current_hunk.update_before(hunk_before);
self.current_hunk.update_after(hunk_after);
// the histogram heuristic does not work as well
// for characters because the same characters often reoccur
// use myer diff instead
imara_diff::diff(
Algorithm::Myers,
&self.current_hunk,
CharChangeSetBuilder {
res: &mut self.res,
hunk: &self.current_hunk,
pos: 0,
},
);
self.current_hunk.clear();
}
}
fn finish(mut self) -> Self::Out {
let len = self.file.before[self.pos as usize..]
.iter()
.map(|&it| self.file.interner[it].len_chars())
.sum();
self.res.retain(len);
self.res
}
}
struct RopeLines<'a>(RopeSlice<'a>);
impl<'a> imara_diff::intern::TokenSource for RopeLines<'a> {
type Token = RopeSlice<'a>;
type Tokenizer = ropey::iter::Lines<'a>;
fn tokenize(&self) -> Self::Tokenizer {
self.0.lines()
}
fn estimate_tokens(&self) -> u32 {
// we can provide a perfect estimate which is very nice for performance
self.0.len_lines() as u32
}
}
/// Compares `old` and `new` to generate a [`Transaction`] describing
/// the steps required to get from `old` to `new`.
pub fn compare_ropes(before: &Rope, after: &Rope) -> Transaction {
let start = Instant::now();
let res = ChangeSet::with_capacity(32);
let after = after.slice(..);
let file = InternedInput::new(RopeLines(before.slice(..)), RopeLines(after));
let builder = LineChangeSetBuilder {
res,
file: &file,
after,
pos: 0,
current_hunk: InternedInput::default(),
};
let res = imara_diff::diff(Algorithm::Histogram, &file, builder).into();
log::debug!(
"rope diff took {}s",
Instant::now().duration_since(start).as_secs_f64()
);
res
}
#[cfg(test)]
mod tests {
use super::*;
fn test_identity(a: &str, b: &str) {
let mut old = Rope::from(a);
let new = Rope::from(b);
compare_ropes(&old, &new).apply(&mut old);
assert_eq!(old, new);
}
quickcheck::quickcheck! {
fn test_compare_ropes(a: String, b: String) -> bool {
let mut old = Rope::from(a);
@ -61,4 +197,25 @@ mod tests {
old == new
}
}
#[test]
fn equal_files() {
test_identity("foo", "foo");
}
#[test]
fn trailing_newline() {
test_identity("foo\n", "foo");
test_identity("foo", "foo\n");
}
#[test]
fn new_file() {
test_identity("", "foo");
}
#[test]
fn deleted_file() {
test_identity("foo", "");
}
}

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

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

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

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

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

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

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

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

@ -122,7 +122,7 @@ impl Range {
}
}
// flips the direction of the selection
/// Flips the direction of the selection
pub fn flip(&self) -> Self {
Self {
anchor: self.head,
@ -131,6 +131,16 @@ impl Range {
}
}
/// Returns the selection if it goes in the direction of `direction`,
/// flipping the selection otherwise.
pub fn with_direction(self, direction: Direction) -> Self {
if self.direction() == direction {
self
} else {
self.flip()
}
}
/// Check two ranges for overlap.
#[must_use]
pub fn overlaps(&self, other: &Self) -> bool {
@ -485,28 +495,53 @@ impl Selection {
/// Normalizes a `Selection`.
fn normalize(mut self) -> Self {
let primary = self.ranges[self.primary_index];
let mut primary = self.ranges[self.primary_index];
self.ranges.sort_unstable_by_key(Range::from);
self.ranges.dedup_by(|curr_range, prev_range| {
if prev_range.overlaps(curr_range) {
let new_range = curr_range.merge(*prev_range);
if prev_range == &primary || curr_range == &primary {
primary = new_range;
}
*prev_range = new_range;
true
} else {
false
}
});
self.primary_index = self
.ranges
.iter()
.position(|&range| range == primary)
.unwrap();
let mut prev_i = 0;
for i in 1..self.ranges.len() {
if self.ranges[prev_i].overlaps(&self.ranges[i]) {
self.ranges[prev_i] = self.ranges[prev_i].merge(self.ranges[i]);
self
}
// Merges all ranges that are consecutive
pub fn merge_consecutive_ranges(mut self) -> Self {
let mut primary = self.ranges[self.primary_index];
self.ranges.dedup_by(|curr_range, prev_range| {
if prev_range.to() == curr_range.from() {
let new_range = curr_range.merge(*prev_range);
if prev_range == &primary || curr_range == &primary {
primary = new_range;
}
*prev_range = new_range;
true
} else {
prev_i += 1;
self.ranges[prev_i] = self.ranges[i];
}
if i == self.primary_index {
self.primary_index = prev_i;
false
}
}
});
self.ranges.truncate(prev_i + 1);
self.primary_index = self
.ranges
.iter()
.position(|&range| range == primary)
.unwrap();
self
}
@ -1122,6 +1157,52 @@ mod test {
&["", "abcd", "efg", "rs", "xyz"]
);
}
#[test]
fn test_merge_consecutive_ranges() {
let selection = Selection::new(
smallvec![
Range::new(0, 1),
Range::new(1, 10),
Range::new(15, 20),
Range::new(25, 26),
Range::new(26, 30)
],
4,
);
let result = selection.merge_consecutive_ranges();
assert_eq!(
result.ranges(),
&[Range::new(0, 10), Range::new(15, 20), Range::new(25, 30)]
);
assert_eq!(result.primary_index, 2);
let selection = Selection::new(smallvec![Range::new(0, 1)], 0);
let result = selection.merge_consecutive_ranges();
assert_eq!(result.ranges(), &[Range::new(0, 1)]);
assert_eq!(result.primary_index, 0);
let selection = Selection::new(
smallvec![
Range::new(0, 1),
Range::new(1, 5),
Range::new(5, 8),
Range::new(8, 10),
Range::new(10, 15),
Range::new(18, 25)
],
3,
);
let result = selection.merge_consecutive_ranges();
assert_eq!(result.ranges(), &[Range::new(0, 15), Range::new(18, 25)]);
assert_eq!(result.primary_index, 0);
}
#[test]
fn test_selection_contains() {
fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool {

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

@ -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 {
PairNotFound,
CursorOverlap,

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

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

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

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

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

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

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

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

@ -1,6 +1,26 @@
use std::borrow::Cow;
use std::process::Command;
const VERSION: &str = include_str!("../VERSION");
fn main() {
let git_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|x| String::from_utf8(x.stdout).ok());
let version: Cow<_> = match git_hash {
Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(),
None => VERSION.into(),
};
println!(
"cargo:rustc-env=BUILD_TARGET={}",
std::env::var("TARGET").unwrap()
);
println!("cargo:rerun-if-changed=../VERSION");
println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version);
}

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

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

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

@ -4,8 +4,8 @@ use crate::{
Call, Error, OffsetEncoding, Result,
};
use anyhow::anyhow;
use helix_core::{find_root, ChangeSet, Rope};
use helix_loader::{self, VERSION_AND_GIT_HASH};
use lsp_types as lsp;
use serde::Deserialize;
use serde_json::Value;
@ -34,7 +34,7 @@ pub struct Client {
pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>,
offset_encoding: OffsetEncoding,
config: Option<Value>,
root_path: Option<std::path::PathBuf>,
root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>,
workspace_folders: Vec<lsp::WorkspaceFolder>,
req_timeout: u64,
@ -42,18 +42,22 @@ pub struct Client {
impl Client {
#[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)]
pub fn start(
cmd: &str,
args: &[String],
config: Option<Value>,
server_environment: HashMap<String, String>,
root_markers: &[String],
id: usize,
req_timeout: u64,
doc_path: Option<&std::path::PathBuf>,
) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> {
// Resolve path to the binary
let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?;
let process = Command::new(cmd)
.envs(server_environment)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@ -72,11 +76,12 @@ impl Client {
let (server_rx, server_tx, initialize_notify) =
Transport::start(reader, writer, stderr, id);
let root_path = find_root(None, root_markers);
let root_path = find_root(
doc_path.and_then(|x| x.parent().and_then(|x| x.to_str())),
root_markers,
);
let root_uri = root_path
.clone()
.and_then(|root| lsp::Url::from_file_path(root).ok());
let root_uri = lsp::Url::from_file_path(root_path.clone()).ok();
// TODO: support multiple workspace folders
let workspace_folders = root_uri
@ -281,10 +286,7 @@ impl Client {
workspace_folders: Some(self.workspace_folders.clone()),
// root_path is obsolete, but some clients like pyright still use it so we specify both.
// clients will prefer _uri if possible
root_path: self
.root_path
.clone()
.and_then(|path| path.to_str().map(|path| path.to_owned())),
root_path: self.root_path.to_str().map(|path| path.to_owned()),
root_uri: self.root_uri.clone(),
initialization_options: self.config.clone(),
capabilities: lsp::ClientCapabilities {
@ -299,6 +301,9 @@ impl Client {
dynamic_registration: Some(false),
..Default::default()
}),
execute_command: Some(lsp::DynamicRegistrationClientCapabilities {
dynamic_registration: Some(false),
}),
..Default::default()
}),
text_document: Some(lsp::TextDocumentClientCapabilities {
@ -312,6 +317,7 @@ impl Client {
String::from("additionalTextEdits"),
],
}),
insert_replace_support: Some(true),
..Default::default()
}),
completion_item_kind: Some(lsp::CompletionItemKindCapability {
@ -374,7 +380,10 @@ impl Client {
..Default::default()
},
trace: None,
client_info: None,
client_info: Some(lsp::ClientInfo {
name: String::from("helix"),
version: Some(String::from(VERSION_AND_GIT_HASH)),
}),
locale: None, // TODO
};
@ -543,16 +552,17 @@ impl Client {
new_text: &Rope,
changes: &ChangeSet,
) -> Option<impl Future<Output = Result<()>>> {
// figure out what kind of sync the server supports
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document sync.
let sync_capabilities = match capabilities.text_document_sync {
Some(lsp::TextDocumentSyncCapability::Kind(kind))
| Some(lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions {
change: Some(kind),
..
})) => kind,
Some(
lsp::TextDocumentSyncCapability::Kind(kind)
| lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions {
change: Some(kind),
..
}),
) => kind,
// None | SyncOptions { changes: None }
_ => return None,
};
@ -628,8 +638,12 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> {
// ) -> Result<Vec<lsp::CompletionItem>> {
) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support completion.
capabilities.completion_provider.as_ref()?;
let params = lsp::CompletionParams {
text_document_position: lsp::TextDocumentPositionParams {
text_document,
@ -644,15 +658,25 @@ impl Client {
// lsp::CompletionContext { trigger_kind: , trigger_character: Some(), }
};
self.call::<lsp::request::Completion>(params)
Some(self.call::<lsp::request::Completion>(params))
}
pub async fn resolve_completion_item(
pub fn resolve_completion_item(
&self,
completion_item: lsp::CompletionItem,
) -> Result<lsp::CompletionItem> {
self.request::<lsp::request::ResolveCompletionItem>(completion_item)
.await
) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support resolving completion items.
match capabilities.completion_provider {
Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..
}) => (),
_ => return None,
}
Some(self.call::<lsp::request::ResolveCompletionItem>(completion_item))
}
pub fn text_document_signature_help(
@ -663,7 +687,7 @@ impl Client {
) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if signature help is not supported
// Return early if the server does not support signature help.
capabilities.signature_help_provider.as_ref()?;
let params = lsp::SignatureHelpParams {
@ -684,7 +708,18 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<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 {
text_document_position_params: lsp::TextDocumentPositionParams {
text_document,
@ -694,7 +729,7 @@ impl Client {
// lsp::SignatureHelpContext
};
self.call::<lsp::request::HoverRequest>(params)
Some(self.call::<lsp::request::HoverRequest>(params))
}
// formatting
@ -707,13 +742,11 @@ impl Client {
) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
let capabilities = self.capabilities.get().unwrap();
// check if we're able to format
// Return early if the server does not support formatting.
match capabilities.document_formatting_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (),
// None | Some(false)
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
};
// TODO: return err::unavailable so we can fall back to tree sitter formatting
// merge FormattingOptions with 'config.format'
let config_format = self
@ -748,22 +781,20 @@ impl Client {
})
}
pub async fn text_document_range_formatting(
pub fn text_document_range_formatting(
&self,
text_document: lsp::TextDocumentIdentifier,
range: lsp::Range,
options: lsp::FormattingOptions,
work_done_token: Option<lsp::ProgressToken>,
) -> anyhow::Result<Vec<lsp::TextEdit>> {
) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
let capabilities = self.capabilities.get().unwrap();
// check if we're able to format
// Return early if the server does not support range formatting.
match capabilities.document_range_formatting_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (),
// None | Some(false)
_ => return Ok(Vec::new()),
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
};
// TODO: return err::unavailable so we can fall back to tree sitter formatting
let params = lsp::DocumentRangeFormattingParams {
text_document,
@ -772,11 +803,13 @@ impl Client {
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
};
let response = self
.request::<lsp::request::RangeFormatting>(params)
.await?;
let request = self.call::<lsp::request::RangeFormatting>(params);
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(
@ -784,7 +817,15 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
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 {
text_document_position_params: lsp::TextDocumentPositionParams {
text_document,
@ -796,7 +837,7 @@ impl Client {
},
};
self.call::<lsp::request::DocumentHighlightRequest>(params)
Some(self.call::<lsp::request::DocumentHighlightRequest>(params))
}
fn goto_request<
@ -829,8 +870,20 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> {
self.goto_request::<lsp::request::GotoDefinition>(text_document, position, work_done_token)
) -> Option<impl Future<Output = Result<Value>>> {
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(
@ -838,12 +891,23 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> {
self.goto_request::<lsp::request::GotoTypeDefinition>(
) -> Option<impl Future<Output = Result<Value>>> {
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,
position,
work_done_token,
)
))
}
pub fn goto_implementation(
@ -851,12 +915,23 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> {
self.goto_request::<lsp::request::GotoImplementation>(
) -> Option<impl Future<Output = Result<Value>>> {
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,
position,
work_done_token,
)
))
}
pub fn goto_reference(
@ -864,7 +939,15 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<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 {
text_document_position: lsp::TextDocumentPositionParams {
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(
&self,
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 {
text_document,
work_done_progress_params: lsp::WorkDoneProgressParams::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
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 {
query,
work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(),
};
self.call::<lsp::request::WorkspaceSymbol>(params)
Some(self.call::<lsp::request::WorkspaceSymbol>(params))
}
pub fn code_actions(
@ -911,7 +1010,18 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
range: lsp::Range,
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 {
text_document,
range,
@ -920,26 +1030,22 @@ impl Client {
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,
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
new_name: String,
) -> anyhow::Result<lsp::WorkspaceEdit> {
) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
let capabilities = self.capabilities.get().unwrap();
// check if we're able to rename
// Return early if the language server does not support renaming.
match capabilities.rename_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (),
// None | Some(false)
_ => {
log::warn!("rename_symbol failed: The server does not support rename");
let err = "The server does not support rename";
return Err(anyhow!(err));
}
_ => return None,
};
let params = lsp::RenameParams {
@ -953,11 +1059,21 @@ impl Client {
},
};
let response = self.request::<lsp::request::Rename>(params).await?;
Ok(response.unwrap_or_default())
let request = self.call::<lsp::request::Rename>(params);
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 {
command: command.command,
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;
// https://www.jsonrpc.org/specification#error_object
#[derive(Debug, PartialEq, Clone)]
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum ErrorCode {
ParseError,
InvalidRequest,
@ -68,7 +68,7 @@ impl Serialize for ErrorCode {
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Error {
pub code: ErrorCode,
pub message: String,
@ -100,7 +100,7 @@ impl std::error::Error for Error {}
// https://www.jsonrpc.org/specification#request_object
/// Request ID
#[derive(Debug, PartialEq, Clone, Hash, Eq, Deserialize, Serialize)]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Id {
Null,
@ -109,7 +109,7 @@ pub enum Id {
}
/// Protocol Version
#[derive(Debug, PartialEq, Clone, Copy, Hash, Eq)]
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub enum Version {
V2,
}
@ -153,7 +153,7 @@ impl<'de> Deserialize<'de> for Version {
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Params {
None,
@ -182,7 +182,7 @@ impl From<Params> for Value {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MethodCall {
pub jsonrpc: Option<Version>,
@ -192,7 +192,7 @@ pub struct MethodCall {
pub id: Id,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Notification {
pub jsonrpc: Option<Version>,
@ -201,7 +201,7 @@ pub struct Notification {
pub params: Params,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
pub enum Call {
@ -235,7 +235,7 @@ impl From<Notification> for Call {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[serde(untagged)]
pub enum Request {
@ -245,7 +245,7 @@ pub enum Request {
// https://www.jsonrpc.org/specification#response_object
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Success {
#[serde(skip_serializing_if = "Option::is_none")]
pub jsonrpc: Option<Version>,
@ -253,7 +253,7 @@ pub struct Success {
pub id: Id,
}
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
pub struct Failure {
#[serde(skip_serializing_if = "Option::is_none")]
pub jsonrpc: Option<Version>,
@ -264,7 +264,7 @@ pub struct Failure {
// Note that failure comes first because we're not using
// #[serde(deny_unknown_field)]: we want a request that contains
// both `result` and `error` to be a `Failure`.
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Output {
Failure(Failure),
@ -280,7 +280,7 @@ impl From<Output> for Result<Value, Error> {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Response {
Single(Output),

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

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

@ -1,30 +1,9 @@
use helix_loader::grammar::{build_grammars, fetch_grammars};
use std::borrow::Cow;
use std::process::Command;
const VERSION: &str = include_str!("../VERSION");
fn main() {
let git_hash = Command::new("git")
.args(&["rev-parse", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|x| String::from_utf8(x.stdout).ok());
let version: Cow<_> = match git_hash {
Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(),
None => VERSION.into(),
};
if std::env::var("HELIX_DISABLE_AUTO_GRAMMAR_BUILD").is_err() {
fetch_grammars().expect("Failed to fetch tree-sitter grammars");
build_grammars(Some(std::env::var("TARGET").unwrap()))
.expect("Failed to compile tree-sitter grammars");
}
println!("cargo:rerun-if-changed=../runtime/grammars/");
println!("cargo:rerun-if-changed=../VERSION");
println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version);
}

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

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

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

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

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

@ -144,14 +144,70 @@ impl DerefMut for KeyTrieNode {
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
#[derive(Debug, Clone, PartialEq)]
pub enum KeyTrie {
Leaf(MappableCommand),
Sequence(Vec<MappableCommand>),
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 {
pub fn node(&self) -> Option<&KeyTrieNode> {
match *self {
@ -334,18 +390,18 @@ impl Keymaps {
self.state.push(key);
match trie.search(&self.state[1..]) {
Some(&KeyTrie::Node(ref map)) => {
Some(KeyTrie::Node(map)) => {
if map.is_sticky {
self.state.clear();
self.sticky = Some(map.clone());
}
KeymapResult::Pending(map.clone())
}
Some(&KeyTrie::Leaf(ref cmd)) => {
Some(KeyTrie::Leaf(cmd)) => {
self.state.clear();
KeymapResult::Matched(cmd.clone())
}
Some(&KeyTrie::Sequence(ref cmds)) => {
Some(KeyTrie::Sequence(cmds)) => {
self.state.clear();
KeymapResult::MatchedSequence(cmds.clone())
}

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

@ -1,5 +1,6 @@
use anyhow::{Context, Error, Result};
use crossterm::event::EventStream;
use helix_loader::VERSION_AND_GIT_HASH;
use helix_term::application::Application;
use helix_term::args::Args;
use helix_term::config::Config;
@ -67,13 +68,14 @@ FLAGS:
-g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml
-c, --config <file> Specifies a file to use for configuration
-v Increases logging verbosity each use for up to 3 times
--log Specifies a file to use for logging
(default file: {})
-V, --version Prints version information
--vsplit Splits all given files vertically into different windows
--hsplit Splits all given files horizontally into different windows
",
env!("CARGO_PKG_NAME"),
env!("VERSION_AND_GIT_HASH"),
VERSION_AND_GIT_HASH,
env!("CARGO_PKG_AUTHORS"),
env!("CARGO_PKG_DESCRIPTION"),
logpath.display(),
@ -88,7 +90,7 @@ FLAGS:
}
if args.display_version {
println!("helix {}", env!("VERSION_AND_GIT_HASH"));
println!("helix {}", VERSION_AND_GIT_HASH);
std::process::exit(0);
}
@ -114,6 +116,7 @@ FLAGS:
return Ok(0);
}
let logpath = args.log_file.as_ref().cloned().unwrap_or(logpath);
setup_logging(logpath, args.verbosity).context("failed to initialize logging")?;
let config_dir = helix_loader::config_dir();
@ -137,8 +140,18 @@ FLAGS:
Err(err) => return Err(Error::new(err)),
};
let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| {
eprintln!("Bad language config: {}", err);
eprintln!("Press <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
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?;

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

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

@ -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_area = area.clip_top(1).with_height(sig_text_height);
let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area);
let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false });
sig_text_para.render(sig_text_area.inner(&margin), surface);
sig_text_para.render(sig_text_area, surface);
if self.signature_doc.is_none() {
return;

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

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

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

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

@ -1,4 +1,5 @@
use helix_core::{coords_at_pos, encoding, Position};
use helix_lsp::lsp::DiagnosticSeverity;
use helix_view::{
document::{Mode, SCRATCH_BUFFER_NAME},
graphics::Rect,
@ -68,7 +69,9 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface
// Left side of the status line.
let element_ids = &context.editor.config().statusline.left;
let config = context.editor.config();
let element_ids = &config.statusline.left;
element_ids
.iter()
.map(|element_id| get_render_function(*element_id))
@ -83,7 +86,7 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface
// Right side of the status line.
let element_ids = &context.editor.config().statusline.right;
let element_ids = &config.statusline.right;
element_ids
.iter()
.map(|element_id| get_render_function(*element_id))
@ -101,7 +104,7 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface
// Center of the status line.
let element_ids = &context.editor.config().statusline.center;
let element_ids = &config.statusline.center;
element_ids
.iter()
.map(|element_id| get_render_function(*element_id))
@ -141,9 +144,14 @@ where
helix_view::editor::StatusLineElement::FileLineEnding => render_file_line_ending,
helix_view::editor::StatusLineElement::FileType => render_file_type,
helix_view::editor::StatusLineElement::Diagnostics => render_diagnostics,
helix_view::editor::StatusLineElement::WorkspaceDiagnostics => render_workspace_diagnostics,
helix_view::editor::StatusLineElement::Selections => render_selections,
helix_view::editor::StatusLineElement::PrimarySelectionLength => {
render_primary_selection_length
}
helix_view::editor::StatusLineElement::Position => render_position,
helix_view::editor::StatusLineElement::PositionPercentage => render_position_percentage,
helix_view::editor::StatusLineElement::TotalLineNumbers => render_total_line_numbers,
helix_view::editor::StatusLineElement::Separator => render_separator,
helix_view::editor::StatusLineElement::Spacer => render_spacer,
}
@ -154,23 +162,24 @@ where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let visible = context.focused;
let config = context.editor.config();
let modenames = &config.statusline.mode;
write(
context,
format!(
" {} ",
if visible {
match context.editor.mode() {
Mode::Insert => "INS",
Mode::Select => "SEL",
Mode::Normal => "NOR",
Mode::Insert => &modenames.insert,
Mode::Select => &modenames.select,
Mode::Normal => &modenames.normal,
}
} else {
// 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() {
Mode::Insert => Some(context.editor.theme.get("ui.statusline.insert")),
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)
where
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 {
coords_at_pos(
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)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,

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

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

@ -1,21 +1,547 @@
use helix_core::{auto_pairs::DEFAULT_PAIRS, hashmap};
use super::*;
#[tokio::test]
async fn auto_pairs_basic() -> anyhow::Result<()> {
test(("#[\n|]#", "i(<esc>", "(#[|)]#\n")).await?;
const LINE_END: &str = helix_core::DEFAULT_LINE_ENDING.as_str();
test_with_config(
Args::default(),
Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Enable(false),
..Default::default()
},
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)
}
#[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()
},
("#[\n|]#", "i(<esc>", "(#[|\n]#"),
)
.await?;
..Default::default()
};
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(())
}

@ -1,21 +1,26 @@
use std::{
io::{Read, Write},
ops::RangeInclusive,
};
use std::ops::RangeInclusive;
use helix_core::diagnostic::Severity;
use helix_term::application::Application;
use super::*;
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn test_write_quit_fail() -> anyhow::Result<()> {
let file = helpers::new_readonly_tempfile()?;
let mut app = helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?;
test_key_sequence(
&mut helpers::app_with_file(file.path())?,
&mut app,
Some("ihello<esc>:wq<ret>"),
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);
}),
false,
@ -25,11 +30,10 @@ async fn test_write_quit_fail() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
#[ignore]
#[tokio::test(flavor = "multi_thread")]
async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
test_key_sequences(
&mut Application::new(Args::default(), Config::default())?,
&mut helpers::AppBuilder::new().build()?,
vec![
(
None,
@ -69,8 +73,12 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
command.push_str(":buffer<minus>close<ret>");
let mut app = helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?;
test_key_sequence(
&mut helpers::app_with_file(file.path())?,
&mut app,
Some(&command),
Some(&|app| {
assert!(!app.editor.is_err(), "error: {:?}", app.editor.get_status());
@ -82,17 +90,12 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
)
.await?;
file.as_file_mut().flush()?;
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);
helpers::assert_file_has_content(file.as_file_mut(), &RANGE.end().to_string())?;
Ok(())
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn test_selection_duplication() -> anyhow::Result<()> {
// Forward
test((
@ -131,3 +134,180 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
.await?;
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 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_view::{doc, input::parse_macro};
use helix_view::{doc, input::parse_macro, Editor};
use tempfile::NamedTempFile;
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() {
if let Some(in_keys) = in_keys {
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
// the inverse
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 {
@ -87,7 +94,17 @@ pub async fn test_key_sequences(
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(())
}
@ -101,20 +118,19 @@ pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>(
let test_case = test_case.into();
let mut app = match 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 sel = doc.selection(view.id).clone();
// replace the initial text with the input text
doc.apply(
&Transaction::change_by_selection(doc.text(), &sel, |_| {
(0, doc.text().len_chars(), Some((&test_case.in_text).into()))
})
.with_selection(test_case.in_selection.clone()),
view.id,
);
let transaction = Transaction::change_by_selection(doc.text(), &sel, |_| {
(0, doc.text().len_chars(), Some((&test_case.in_text).into()))
})
.with_selection(test_case.in_selection.clone());
helix_view::apply_transaction(&transaction, doc, view);
test_key_sequence(
&mut app,
@ -125,16 +141,48 @@ pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>(
.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
/// document, selection, and sequence of key presses, and you just
/// want to verify the resulting document and selection.
pub async fn test_with_config<T: Into<TestCase>>(
args: Args,
config: Config,
syn_conf: helix_core::syntax::Configuration,
test_case: T,
) -> anyhow::Result<()> {
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(
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<()> {
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>>(
@ -200,14 +254,87 @@ pub fn new_readonly_tempfile() -> anyhow::Result<NamedTempFile> {
Ok(file)
}
/// Creates a new Application with default config that opens the given file
/// path
pub fn app_with_file<P: Into<PathBuf>>(path: P) -> anyhow::Result<Application> {
Application::new(
Args {
files: vec![(path.into(), helix_core::Position::default())],
..Default::default()
},
Config::default(),
)
pub struct AppBuilder {
args: Args,
config: Config,
syn_conf: helix_core::syntax::Configuration,
input: Option<(String, Selection)>,
}
impl Default for AppBuilder {
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::*;
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn insert_mode_cursor_position() -> anyhow::Result<()> {
test(TestCase {
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
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> {
test(("#[f|]#oo\n", "vll<A-;><esc>", "#[|foo]#\n")).await?;
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
/// the first grapheme
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn cursor_position_newly_opened_file() -> anyhow::Result<()> {
let test = |content: &str, expected_sel: Selection| -> anyhow::Result<()> {
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 sel = doc.selection(view.id).clone();
@ -85,3 +87,156 @@ async fn cursor_position_newly_opened_file() -> anyhow::Result<()> {
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 helix_term::application::Application;
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
async fn test_history_completion() -> anyhow::Result<()> {
test_key_sequence(
&mut Application::new(Args::default(), Config::default())?,
&mut AppBuilder::new().build()?,
Some(":asdf<ret>:theme d<C-n><tab>"),
Some(&|app| {
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